River
Lv.1 · 0% done0 XP
0/12 sections
Universal Query Language

River Reference

Query PostgreSQL, MySQL, SQLite, MongoDB, and SQL Server through a single consistent syntax. Read the docs, earn XP, and collect badges as you learn.

River is a superset of SQL. You can write standard SQL for basic operations like SELECT * FROM users — River's extended clauses (find, @connection, >>, ai_query()) layer on top for cross-database and AI workloads.

Total XP:0
Sections Complete:0 / 12
Badges Earned:0 / 9
1
Section 1· +50 XP on complete

Installation

Get River up and running on your machine. Choose the method that fits your workflow.

Manual Install

Download the latest binary for your platform from the releases page.

Build from Source

Clone the repository and build with Cargo.

from source
git clone https://github.com/bryanbill/river.git
cd river
cargo build --release
./target/release/river --help

Verify Installation

After installing, verify River is available on your system.

verify
river --help
TIP
After installing, check out the next section — Query Basics — to write your first River query.
2
Section 2· +60 XP on complete

Configuration

Set up database connections and run RiverQL scripts. River reads river.yaml at startup to discover your databases, and .rql files to execute saved queries.

Connecting Your Databases (river.yaml)

Create a river.yaml file in your working directory. River reads it on startup and registers every connection listed.

river.yaml
- name: pg
kind: postgres
uri: "postgres://user:pass@localhost:5432/mydb"
schema: public
 
- name: mysql
kind: mysql
uri: "mysql://user:pass@localhost:3306/mydb"
schema: mydb
 
- name: mongo
kind: mongodb
uri: "mongodb://localhost:27017"
 
- name: sqlite
kind: sqlite
uri: "sqlite:local.db?mode=rwc"
 
- name: mssql
kind: mssql
uri: "sqlserver://user:pass@localhost:1433/mydb"
- name: claude
kind: ai
provider: anthropic
uri: "https://api.anthropic.com"
api_key: "${ANTHROPIC_API_KEY}"
model: "claude-opus-4-6"
 
NOTE
Avoid hyphens (-) in connection names. Use underscores instead (e.g. my_pg not my-pg).

Using a Custom Config Path

Point to any YAML file with the --config flag. The default is river.yaml in the current directory.

custom config
river --config /path/to/production.yaml

Connection Fields

FieldRequiredDescription
nameyesConnection name — used with @name syntax in queries
kindyesDatabase kind: postgres, mysql, mssql, sqlite, mongodb, ai
uriyesConnection string for the database
api_keynoAPI key for external services (e.g. AI providers)
modelnoModel name for AI providers
schemanoDefault schema (defaults to connection's native default)

Running .rql Scripts

Pass a .rql (RiverQL) script file as the first argument to run saved queries without entering the TUI. This is ideal for scripting, automation, and CI pipelines.

script examples
# Run a script and print results as a table
river users.rql
 
# Run silently — no output, just an exit code (great for cron/CI)
river users.rql -s
 
# Export results to a file (format inferred from extension)
river users.rql --out users.csv
river users.rql --out users.xlsx
river users.rql --out users.json
river users.rql --out users.txt
river users.rql --out users.xml

Script File Format

A .rql file is plain text containing RiverQL statements. Separate multiple statements with semicolons — just like in the TUI.

example .rql file
-- users.rql
:threshold = 1000;
find [name, email, total] from users
where total > :threshold
order by total desc
limit 20;
 
describe users;
show tables
TIP
.rql scripts reuse the exact same engine pipeline (parser, planner, translator, executor) as the TUI and MCP server — behavior is identical across all modes.

Export Formats

ExtensionFormatDescription
.csvCSVComma-separated values, compatible with spreadsheets
.xlsxExcelNative Excel workbook (Open XML)
.jsonJSONArray of objects, one per row
.txtText / TSVTab-separated plain text
.xmlXMLStructured XML document
3
Section 3· +100 XP on complete

Query Basics

Every RiverQL query starts with find. It's the universal entry point that maps to SELECT in SQL. Write once, run on PostgreSQL, MySQL, SQLite, MongoDB, or SQL Server.

find — SELECT

Retrieve all columns from a table. The simplest possible query.

riverql
find users

Equivalent to SELECT * FROM users in standard SQL.

Column Selection

Use square brackets to pick specific columns instead of fetching everything.

riverql
find [name, email] from users
combined
find [name, email, department] from users
where status = "active"

Filtering with where

Filter rows using where. Chain conditions with and / or.

basic filter
find users where status = "active"
chained
find users where status = "active" and age > 21

Sorting with order by

Sort results ascending or descending with order by.

single sort
find [name, salary] from users
order by salary desc
multi-key sort
find [name, department, salary] from employees
order by department asc, salary desc

Limiting & Pagination

Control result count with limit and paginate using offset.

limit
find users limit 10
pagination — page 3 of 20-row pages
find [name, email] from users
order by created_at desc
limit 20 offset 40
TIP
Clause order is strict: findfromwhereorder bylimitoffset

DISTINCT — Remove Duplicates

Use distinct after find to eliminate duplicate rows from your results. It acts as a row-level deduplication pass — if every column in a row matches another row, only one copy is kept.

distinct queries
-- Returns unique department names (not one row per employee)
find distinct [department] from employees
 
-- DISTINCT with multiple columns — entire row must match
find distinct [department, status] from employees
 
-- DISTINCT composes with WHERE, ORDER BY, LIMIT
find distinct [department] from employees
where salary > 50000
order by department asc
limit 10
DISTINCT vs GROUP BY: use distinct for simple deduplication. Use group by when you need aggregates like count(*) or sum(...) per group. For counting unique values within a group, use count_distinct(expr).

Full Combined Query

complete example
find [name, email, department, salary] from users
where status = "active" and salary > 50000
order by salary desc
limit 10
4
Section 4· +120 XP on complete

Expressions & Operators

RiverQL supports a rich set of operators for comparisons, logic, pattern matching, arithmetic, type casting, and time intervals.

Comparison Operators

OperatorMeaningExample
=Equalstatus = "active"
!=Not equalstatus != "banned"
<>Not equal (alt)status <> "inactive"
>Greater thanage > 21
>=Greater or equalsalary >= 75000
<Less thanprice < 100
<=Less or equaltotal <= 50.00
examples
find users where age > 21
find users where salary >= 75000
find products where price < 100

Logical Operators

Combine conditions with and, or, and not. Use parentheses to control precedence.

logical
find users where status = "active" and department = "Engineering"
find users where department = "Sales" or department = "Marketing"
find users where not status = "banned"
parentheses grouping
find users
where (department = "Sales" or department = "Marketing")
and salary > 60000

NULL Handling

null checks
find users where deleted_at is null
find users where email_verified_at is not null
null functions
-- coalesce: first non-null value
find [name, coalesce(nickname, name) as display_name] from users
 
-- nullif: returns NULL if values are equal (avoids division by zero)
find [name, nullif(discount, 0) as effective_discount] from products
 
-- ifnull: two-argument coalesce shorthand
find [name, ifnull(phone, "N/A") as contact] from users

BETWEEN / IN / NOT IN

between
find [name, created_at] from users
where created_at between "2024-01-01" and "2024-12-31"
 
find products where price between 10 and 50
in / not in
find users where status in ("active", "pending")
find users where department not in ("HR", "Legal")

Pattern Matching — LIKE / ILIKE

% matches any number of characters. _ matches exactly one character. ilike is case-insensitive.

pattern matching
find users where name like "%smith%"
find users where email like "%@gmail.com"
find users where name like "J_n"
find users where name ilike "%smith%" -- case-insensitive

Arithmetic & String Concat

arithmetic & concat
find [name, price * 1.1 as price_with_tax] from products
find [first || " " || last as full_name] from users
find [abs(amount), round(price, 2), ceil(score), floor(score)] from products

Type Casting

casting
-- Function style
find [name, cast(age as string) as age_str] from users
 
-- Shorthand operator ::
find [name, created_at::string as date_str] from users
NOTE
Supported types: string, integer, float, boolean, datetime, json

Interval Literals

Use shorthand suffixes for relative time calculations with now().

intervals
find users where created_at > now() - 30d
find users where last_login < now() - 90d
find users where created_at > now() - 1h
SuffixMeaning
yYears
monMonths
wWeeks
dDays
hHours
mMinutes
sSeconds

Named Parameters

named params
:start_date = "2024-01-01"
:end_date = "2024-12-31"
 
find [name, total, created_at] from orders
where created_at between :start_date and :end_date
5
Section 5· +150 XP on complete

Joins

Joins combine rows from two or more tables based on a related column. River supports all standard join types with concise, readable syntax.

Inner Join

Returns only rows that have matching values in both tables. join is shorthand for inner join.

inner join
find [u.name, o.total]
from users as u
join orders as o on u.id = o.user_id

Left Join

Returns all rows from the left table, with matched rows from the right (or NULL if no match).

left join
find [u.name, o.total]
from users as u
left join orders as o on u.id = o.user_id

Right Join

Returns all rows from the right table, with matched rows from the left.

right join
find [u.name, o.total]
from users as u
right join orders as o on u.id = o.user_id

Full Join

Returns all rows from both tables, with NULLs where there is no match on either side.

full join
find [u.name, o.total]
from users as u
full join orders as o on u.id = o.user_id

Cross Join

Returns the Cartesian product of both tables. No ON clause needed. Use with caution on large tables.

cross join
find [u.name, p.name as product]
from users as u
cross join products as p
limit 100
WARN
Cross joins on large tables can produce millions of rows. Always add limit.

Multiple Joins

Chain joins to combine three or more tables in a single query.

3-table join
find [u.name, o.total, p.name as product]
from users as u
join orders as o on u.id = o.user_id
join order_items as oi on o.id = oi.order_id
join products as p on oi.product_id = p.id

Self Join

Join a table to itself using different aliases — great for hierarchical data like org charts.

self join
find [e.name, m.name as manager]
from employees as e
left join employees as m on e.manager_id = m.id

Joins with Filters

join + filter + sort
find [u.name, o.total, o.status]
from users as u
join orders as o on u.id = o.user_id
where o.status = "paid" and o.total > 100
order by o.total desc
limit 20

Join Type Reference

TypeKeywordReturns
Innerjoin / inner joinMatching rows from both tables
Leftleft joinAll left rows + matched right (NULL if none)
Rightright joinAll right rows + matched left (NULL if none)
Fullfull joinAll rows from both, NULLs for no match
Crosscross joinCartesian product (no ON clause)
6
Section 6· +130 XP on complete

Aggregation

Aggregate functions compute a single value from a set of rows. Combine with group by to summarize groups, and having to filter them.

Aggregate Functions

FunctionDescription
count(*)Count all rows
count(expr)Count non-NULL values
count_distinct(expr)Count distinct non-NULL values
sum(expr)Sum of values
avg(expr)Average (mean)
min(expr)Minimum value
max(expr)Maximum value
single aggregates
find [count(*)] from users
find [sum(total)] from orders
find [avg(salary)] from employees
find [min(price), max(price)] from products

Multiple Aggregates

multiple aggregates
find [
count(*) as total_orders,
sum(total) as revenue,
avg(total) as avg_order,
min(total) as smallest,
max(total) as largest
]
from orders
where status = "paid"

GROUP BY

Group rows by one or more columns, then apply aggregates to each group.

single group
find [department, count(*) as headcount]
from employees
group by department
multi-column group
find [department, status, count(*) as cnt]
from employees
group by department, status

GROUP BY with Full Projection

full projection
find [
category,
count(*) as product_count,
avg(price) as avg_price,
min(price) as cheapest,
max(price) as most_expensive
]
from products
group by category
order by product_count desc

HAVING — Filter Groups

having filters groups after aggregation. Unlike where, it operates on aggregate values.

having
find [user_id, count(*) as order_count]
from orders
group by user_id
having count(*) > 5
TIP
where filters individual rows before grouping.
having filters groups after aggregation.

WHERE + HAVING Combined

where + having
find [department, avg(salary) as avg_sal]
from employees
where status = "active"
group by department
having avg(salary) > 75000

Aggregation with Joins

joins + aggregation
find [u.name, count(*) as order_count, sum(o.total) as total_spent]
from users as u
join orders as o on u.id = o.user_id
where o.status = "paid"
group by u.name
having total_spent > 1000
order by total_spent desc
limit 10
7
Section 7· +175 XP on complete

Window Functions

Window functions perform calculations across related rows without collapsing them. They preserve every row while computing values like rankings, running totals, and moving averages.

Window Syntax

syntax
function() over (partition by col order by col)
NOTE
partition by divides rows into groups (like GROUP BY, but rows are preserved).
order by defines row order within each partition.

ROW_NUMBER

Assigns a unique sequential integer to each row within its partition, ordered by the specified column.

row_number
find [
name,
department,
salary,
row_number() over (partition by department order by salary desc) as rank
]
from employees

RANK and DENSE_RANK

rank() leaves gaps after ties. dense_rank() does not.

rank vs dense_rank
-- rank(): ties share a rank, next rank has a gap (1, 1, 3...)
find [name, score, rank() over (order by score desc) as position]
from players
 
-- dense_rank(): no gaps (1, 1, 2...)
find [name, score, dense_rank() over (order by score desc) as position]
from players

LAG and LEAD

Access values from previous or next rows within the partition.

lag / lead
find [
date,
revenue,
lag(revenue, 1) over (order by date) as prev_day,
lead(revenue, 1) over (order by date) as next_day
]
from daily_stats
day-over-day
-- Day-over-day change
find [
date,
revenue,
revenue - lag(revenue, 1) over (order by date) as daily_change
]
from daily_stats

Running Totals

running total
find [
date,
amount,
sum(amount) over (order by date) as running_total
]
from transactions

Aggregates Over Windows

aggregate window
find [
name,
department,
salary,
avg(salary) over (partition by department) as dept_avg,
salary - avg(salary) over (partition by department) as diff_from_avg
]
from employees

Named Windows

Define a reusable window spec with window to avoid repetition.

named window
find [
name,
department,
salary,
avg(salary) over w as dept_avg,
max(salary) over w as dept_max,
min(salary) over w as dept_min
]
from employees
window w as (partition by department)

Top N Per Group

top-n per group
-- Top 3 highest-paid employees per department
find * from (
find [
name, department, salary,
row_number() over (partition by department order by salary desc) as rn
]
from employees
) as ranked
where rn <= 3

Window Function Reference

FunctionDescription
row_number()Unique sequential number per partition
rank()Rank with gaps after ties
dense_rank()Rank without gaps
lag(expr, N)Value from N rows before
lead(expr, N)Value from N rows after
first_value(expr)First value in the window frame
last_value(expr)Last value in the window frame
nth_value(expr, N)Nth value in the window frame
sum/avg/min/max over (...)Any aggregate used as a window function
8
Section 8· +200 XP on complete

Advanced Queries

CTEs, subqueries, set operations, CASE expressions, and cross-database joins — the full toolkit for complex data retrieval.

CTEs — Common Table Expressions

Define temporary named result sets with with. Makes complex queries readable and composable.

simple CTE
with active_users as (
find * from users where status = "active"
)
find [name, email] from active_users
chained CTEs
with
paid_orders as (
find * from orders where status = "paid"
),
user_totals as (
find [user_id, sum(total) as revenue]
from paid_orders
group by user_id
)
find [u.name, ut.revenue]
from users as u
join user_totals as ut on u.id = ut.user_id
where ut.revenue > 10
order by ut.revenue desc

Recursive CTEs

Traverse hierarchical data like org charts and category trees using with recursive.

recursive CTE
with recursive org_tree as (
-- Base case: top-level employees
find * from employees where manager_id is null
union all
-- Recursive: join children to parent
find [e.*]
from employees as e
join org_tree as t on e.manager_id = t.id
)
find * from org_tree

Subqueries

scalar subquery
-- Scalar subquery in WHERE
find [name, salary] from users
where salary > (
find [avg(salary)] from users
)
IN subquery
-- IN subquery
find [name, department] from employees
where department in (
find distinct [department] from departments
where budget > 100000
)
exists / not exists
-- EXISTS / NOT EXISTS
find [name] from users as u
where exists (
find [1] from orders as o where o.user_id = u.id
)
 
-- Find users with no orders
find [name] from users as u
where not exists (
find [1] from orders as o where o.user_id = u.id
)
derived table
-- Derived table (subquery in FROM)
find * from (
find [user_id, sum(total) as revenue]
from orders
group by user_id
) as user_revenue
where revenue > 500

Set Operations — UNION

Set operations combine rows from two or more find queries into a single result. River supports union, union all, intersect, and except. Each query must return the same number of columns.

union vs union all
-- UNION (deduplicates — slowest)
find [name, email] from customers
union
find [name, email] from suppliers
 
-- UNION ALL (keeps duplicates — fastest)
find [name, email] from customers
union all
find [name, email] from suppliers
TIP
Rule of thumb: use union all unless you need deduplication. It skips the expensive dedup pass and is measurably faster on large result sets.

Set Operations — Practical Example

A common pattern: persist subsets, then combine them with set operations.

categorize then union
:status = "active"
 
-- Persist active users
find [name, email, created_at] from users@mongo
where status = :status >> active_users@mysql;
 
-- Persist inactive users
find [name, email, created_at] from users@mongo
where status != :status >> inactive_users@mysql;
 
-- Combine both groups
find distinct [email, name] from active_users@mysql
union all
find distinct [email, name] from inactive_users@mysql

Set Operations with ORDER BY / LIMIT

When a set operation has order by or limit, those clauses apply to the final combined result.

set operation with order and limit
find [name, email] from customers where country = "US"
union all
find [name, email] from suppliers where country = "US"
order by name asc
limit 50

Set Operations — Chaining

Set operations are left-associative — you can chain multiple queries together. Each pair is processed left-to-right.

chained set operations
find [name] from employees
union
find [name] from contractors
union all
find [name] from interns

Set Operations — INTERSECT & EXCEPT

intersect returns rows present in both queries.except returns rows from the first query that are not in the second.

intersect and except
-- INTERSECT (rows in both)
find [user_id] from orders where year = 2024
intersect
find [user_id] from orders where year = 2025
 
-- EXCEPT (rows only in first)
find [user_id] from users
except
find [user_id] from orders where created_at > now() - 30d
INTERSECT and EXCEPT both perform implicit deduplication. There are no intersect all or except all variants.

DISTINCT

Remove duplicate rows from a single query result. Place distinct right after find and before the column list.

distinct queries
-- Get unique departments (may return 5 rows from 1,000 employees)
find distinct [department] from employees
 
-- DISTINCT with multiple columns — deduplication on the full row
find distinct [department, status] from employees
 
-- DISTINCT with WHERE, ORDER BY, LIMIT
find distinct [department] from employees
where salary > 50000
order by department asc
limit 10
TIP
DISTINCT vs GROUP BY: use distinct when you just want unique rows. Use group by when you need aggregates (count, sum, avg) per group. For counting unique values inside a group, use count_distinct(expr).

DISTINCT with Set Operations

Combine distinct with set operations for precise deduplication control at each stage.

distinct with union all
-- Deduplicate within each set, then union-all (no global dedup)
find distinct [email] from users
union all
find distinct [email] from newsletter_subscribers

Set Operations — Quick Reference

OperationBehaviorUse Case
distinctRemove duplicates within a single queryList unique departments
unionCombine two queries, remove duplicatesMerge customer + supplier lists
union allCombine two queries, keep all rowsFastest combine (no dedup pass)
intersectRows common to both queriesFind overlap between two data sets
exceptRows in first but not secondFind rows missing from the second set

CASE Expressions

searched case
-- Searched CASE
find [
name, salary,
case
when salary < 50000 then "Low"
when salary >= 50000 and salary < 100000 then "Medium"
when salary >= 100000 then "High"
else "Unknown"
end as salary_band
]
from users
simple case
-- Simple CASE
find [
name,
case status
when "active" then "Active User"
when "suspended" then "Suspended"
else "Unknown"
end as status_label
]
from users
case in order by
-- CASE in ORDER BY for custom sort priority
find [name, priority] from tasks
order by case priority
when "urgent" then 1
when "high" then 2
when "normal" then 3
else 4
end

Cross-Database Queries

Append @connection to any table name to query across different database systems.

cross-database join
-- Join PostgreSQL users with MySQL orders
find [u.name, o.total]
from users@pg as u
join orders@mysql as o on u.id = o.user_id
where o.status = "paid"
cross-database CTE
-- Cross-database CTE
with
pg_users as (
find [id, name] from users@pg
where status = "active"
),
mongo_logs as (
find [user_id, action, timestamp]
from logs@mongo
where timestamp > now() - 7d
)
find [pg_users.name, count(*) as login_count]
from pg_users
join mongo_logs on pg_users.id = mongo_logs.user_id
where mongo_logs.action = "login"
group by pg_users.name
order by login_count desc
limit 10
TIP
Push where filters before cross-database joins to minimize data transfer. Index join columns on both sides.
9
Section 9· +140 XP on complete

Data Modification

River maps SQL DML and DDL to human-readable commands: create for INSERT, update for UPDATE, remove for DELETE, and standard create table, alter table, drop table, create database, and drop database syntax.

INSERT — create

create (INSERT)
-- Single row
create users { name: "Alice", email: "alice@example.com", age: 30 }
 
-- Multiple rows
create users [
{ name: "Alice", email: "alice@example.com" },
{ name: "Bob", email: "bob@example.com" },
{ name: "Carol", email: "carol@example.com" }
]
 
-- Insert from query
create active_users_backup (
find * from users where status = "active"
)
 
-- Target a specific connection
create users@pg { name: "Dave", email: "dave@example.com" }

UPDATE

Modify existing rows with update ... set ... where. Always include where unless you intend to update all rows.

update
-- Basic update
update users
set status = "inactive", updated_at = now()
where last_login < now() - 90d
 
-- Update with expressions
update products
set price = price * 1.1
where category = "premium"
 
-- Target a specific connection
update users@pg
set status = "verified"
where email_verified_at is not null
WARN
Without where, the update applies to ALL rows in the table.

DELETE — remove

remove (DELETE)
-- Basic delete
remove users where status = "banned"
 
-- Delete with subquery
remove users
where id not in (
find distinct [user_id] from orders
where created_at > now() - 365d
)
 
-- Delete on a specific connection
remove logs@mongo
where timestamp < now() - 30d
WARN
Without where, all rows are deleted. Always use where unless you intend a full-table deletion.

CREATE TABLE

create table
-- Basic table
create table products (
name string,
price float,
category string default "general",
created_at datetime
)
 
-- With primary key and constraints
create table users (
id int primary key,
name string not null,
email string not null,
status string default "active"
)
 
-- Idempotent (if not exists)
create table if not exists cache (
key string primary key,
value json,
expires_at datetime
)

ALTER TABLE

alter table
alter table users add column bio string
alter table users add column tier string not null default "free"
alter table users drop column temp_data
alter table users alter column age type float
alter table users alter column status type string not null default "active"
alter table users alter column status drop default
alter table users rename column name to full_name
alter table users rename to customers
alter table users@pg add column notes string

DROP TABLE

drop table
-- Basic drop
drop table users
 
-- With IF EXISTS (safe for scripts)
drop table if exists temp_logs
 
-- CASCADE (drop dependent objects)
drop table users cascade
 
-- RESTRICT (refuse if dependencies exist)
drop table users restrict
 
-- On a specific connection or schema
drop table if exists archive.logs@pg cascade

CREATE DATABASE

Create a new database on a connected server with optional if not exists guard.

create database
-- Basic create
create database analytics
 
-- Idempotent (if not exists)
create database if not exists analytics
 
-- On a specific connection
create database analytics@pg
create database reports@mysql
NOTE
SQLite note: SQLite does not support server-level databases. Use separate .db files with different URIs instead.
MongoDB note: MongoDB databases are auto-created on first document insertion. create database is accepted but is effectively a no-op.

DROP DATABASE

Remove a database from a connected server with optional if exists guard.

drop database
-- Basic drop
drop database analytics
 
-- Safe for scripts
drop database if exists temp_logs
 
-- On a specific connection
drop database analytics@pg
drop database reports@mysql
WARN
DROP DATABASE is irreversible. All data in the database is permanently deleted.
SQLite note: Use filesystem operations to delete the .db file.
MongoDB note: drop database maps to MongoDB's db.dropDatabase() command.

Persisting Query Results — >>

Save results of any query to a table using the >> operator.

persist query results
-- Simple persist
find * from users >> user_backup
 
-- With conflict handling (upsert)
find [user_id, sum(total) as revenue]
from orders
group by user_id
>> user_revenue@pg
insert if exists on conflict replace
 
-- Ignore duplicates
find distinct [email] from new_signups
>> verified_emails
insert if exists on conflict ignore

Operations Reference

OperationRiverSQL Equivalent
Insert onecreate t { ... }INSERT INTO t VALUES (...)
Insert manycreate t [{ ... }, { ... }]INSERT INTO t VALUES (...), (...)
Insert from querycreate t (find ...)INSERT INTO t SELECT ...
Persist resultsfind ... >> targetCREATE TABLE AS + INSERT
Upsert>> target insert if exists on conflict replaceON CONFLICT DO UPDATE
Updateupdate t set ... where ...UPDATE t SET ... WHERE ...
Deleteremove t where ...DELETE FROM t WHERE ...
Create tablecreate table t (...)CREATE TABLE t (...)
Alter tablealter table t [add|drop|alter|rename]ALTER TABLE t ...
Drop tabledrop table [if exists] t [cascade|restrict]DROP TABLE [IF EXISTS] t [CASCADE|RESTRICT]
Create databasecreate database [if not exists] nameCREATE DATABASE [IF NOT EXISTS] name
Drop databasedrop database [if exists] nameDROP DATABASE [IF EXISTS] name
10
Section 10· +80 XP on complete

Meta Commands

Meta commands inspect database structure and query plans without fetching application data. Essential tools for exploration and optimization.

DESCRIBE

View the schema of any table — column names, types, constraints, and defaults.

describe
describe users
 
-- Target a specific connection
describe users@pg
describe orders@mysql
describe inventory.products@pg

SHOW TABLES

List all tables (or collections) in the current or specified connection.

show tables
show tables
 
-- For a specific connection
show tables @pg
show tables @mongo
show tables @mysql

EXPLAIN

View the execution plan without running the query. Essential before executing expensive operations.

explain
explain find [name] from users
where department = "Engineering"
order by salary desc
cross-db explain
-- Cross-database explain
explain find [u.name, o.total]
from users@pg as u
join orders@mysql as o on u.id = o.user_id
TIP
Always explain before running expensive cross-database joins to understand the fetch strategy.

Named Parameters

Define reusable session-scoped values with the : prefix. Persist for the duration of the session.

named params
:start_date = "2024-01-01"
:end_date = "2024-12-31"
:min_amount = 100
 
find [name, total, created_at] from orders
where created_at between :start_date and :end_date
and total > :min_amount

Comments

comments
-- This is a single-line comment
find users -- inline comment
 
/* This is a
multi-line comment */
find users where status = "active"

Multiple Statements

Separate multiple statements with semicolons to execute them in sequence.

multiple statements
:threshold = 1000;
find [name, total] from orders where total > :threshold;
describe orders

The Cardinal Rules

RuleDescription
1. Every query starts with findfind is the universal SELECT keyword
2. Strict clause orderfind → from → where → group by → having → order by → limit → offset
3. Always use where with update/removeOmitting where affects all rows
4. @connection for cross-databaseAppend @name to route queries to specific databases
5. explain before expensive queriesUnderstand the plan before executing it
11
Section 11· +100 XP on complete

MCP Server

River ships with a Model Context Protocol (MCP) server that exposes the query engine to AI agents. Let your LLM-powered tools query any database through a single, consistent interface.

Starting the Server

Start River in MCP server mode with the --server flag.

start mcp server
river --server
river --server --config /path/to/river.yaml

Available Tools

The MCP server exposes six tools that AI agents can use to interact with databases.

ToolDescription
river_queryExecute any River query (SELECT, INSERT, UPDATE, DELETE, DDL)
river_describeDescribe a table's schema (columns, types, nullability)
river_list_tablesList all tables/collections on a connection
river_explainGet the native SQL/MQL equivalent of a River query
river_list_connectionsList all configured database connections
river_helpReturn River syntax reference by topic

Available Resources

Pre-built documentation resources the agent can read without executing queries.

URIContent
river://docsFull RiverQL language reference
river://docs/quickrefQuick reference: keywords, operators, functions
river://docs/keywordsKeyword-to-purpose mapping table

Claude Desktop

Add River to ~/Library/Application Support/Claude/claude_desktop_config.json on macOS, or %APPDATA%/Claude/claude_desktop_config.json on Windows.

claude desktop config
{
"mcpServers": {
"river": {
"command": "river",
"args": ["--server", "--config", "/path/to/river.yaml"]
}
}
}

Claude Code

Add River to .claude/settings.json in your project root, or ~/.claude/settings.json for user-level configuration.

claude code config
{
"mcpServers": {
"river": {
"command": "river",
"args": ["--server", "--config", "/path/to/river.yaml"]
}
}
}

Cursor

Add River to .cursor/mcp.json in your project root.

cursor config
{
"mcpServers": {
"river": {
"command": "river",
"args": ["--server", "--config", "/path/to/river.yaml"]
}
}
}

opencode

Add River to ~/.config/opencode/opencode.jsonc.

opencode config
{
"mcpServers": {
"river": {
"type": "local",
"command": [
"river",
"--server",
"--config",
"/path/to/river.yaml"
],
"enabled": true
}
}
}

VS Code

With any MCP extension installed, add River to settings.json.

vscode config
{
"mcp.servers": {
"river": {
"command": "river",
"args": ["--server", "--config", "/path/to/river.yaml"]
}
}
}
NOTE
If river is not on your $PATH, use the absolute path (e.g. ./target/release/river). Build first with cargo build --release.
12
Section 12· +150 XP on complete

AI Functions

Call Large Language Models inline within your queries with ai_query. Enrich database results with AI-powered summaries, classifications, translations, and more — all without leaving your query.

How It Works

ai_query sends a prompt to a configured AI provider for each row in your result set. The AI call runs after the database query completes — River processes each row through the LLM and returns the enriched result.

NOTE
AI functions are evaluated in Rust per-row. They cannot be pushed down to database SQL engines — AI calls happen on the River side via HTTP to your chosen provider.

Syntax

Two forms: with default model from config, or with a model override for the query.

ai_query syntax
ai_query(config_name, prompt)
ai_query(config_name, model, prompt)
ArgumentDescription
config_nameAI connection name defined in river.yaml
model(optional) Model name override
promptExpression evaluated per row — use concat(), column refs, literals

Basic Examples

ai_query examples
-- Summarize reviews
find [review_text, ai_query("openai", concat("Summarize: ", review_text)) as summary]
from reviews limit 5
 
-- Classify with a specific model
find [
title,
ai_query("openai", "gpt-4o-mini", concat("Classify as bug, feature, or question: ", title)) as category
]
from support_tickets
where created_at > now() - 7d
 
-- Translate text
find [
message,
ai_query("gemini", "gemini-2.0-flash", concat("Translate to French: ", message)) as french
]
from chat_messages

Supported Providers

ProviderDescription
openaiOpenAI-compatible protocol — covers OpenAI, Deepseek, Kimi, Ollama, vLLM, Groq, Together, Mistral, Perplexity, xAI
anthropicAnthropic Claude — native Messages API (Sonnet, Haiku, Opus)
geminiGoogle Gemini — native generateContent API (AI Studio and Vertex AI)

Configuration

AI connections are defined in river.yaml alongside database connections, using kind: ai.

river.yaml ai config
# OpenAI
- name: openai
kind: ai
provider: openai
uri: "https://api.openai.com/v1"
api_key: "${OPENAI_API_KEY}"
model: "gpt-4o"
 
# Anthropic Claude
- name: claude
kind: ai
provider: anthropic
uri: "https://api.anthropic.com"
api_key: "${ANTHROPIC_API_KEY}"
model: "claude-sonnet-4-20250514"
 
# Google Gemini
- name: gemini
kind: ai
provider: gemini
uri: "https://generativelanguage.googleapis.com/v1beta"
api_key: "${GEMINI_API_KEY}"
model: "gemini-2.0-flash"
 
# OpenAI-compatible (Deepseek, Ollama, etc.)
- name: deepseek
kind: ai
uri: "https://api.deepseek.com/v1"
api_key: "${DEEPSEEK_API_KEY}"
model: "deepseek-chat"
 
- name: ollama
kind: ai
uri: "http://localhost:11434/v1"
api_key: "ollama"
model: "llama3"
FieldRequiredDefaultDescription
nameyesConnection name used in ai_query
kindyesMust be ai
providernoopenaiopenai, anthropic, or gemini
uriyesAPI base URL
api_keyyesAPI key (supports ${ENV_VAR})
modelyesDefault model name
headersno{}Additional HTTP headers
concurrencyno10Max parallel AI calls
timeout_secsno60HTTP request timeout
max_tokensno1024Max response tokens
temperatureno0.0Model temperature

Advanced Patterns

Combine AI functions with CTEs, subqueries, and cross-database joins for powerful data pipelines.

advanced ai patterns
-- AI-powered filtering with CTE
with enriched as (
find [
id,
review_text,
ai_query("openai", "gpt-4o-mini", concat("Is this a complaint? Answer only yes or no: ", review_text)) as is_complaint
]
from reviews
)
find [id, review_text] from enriched
where is_complaint = "yes"
 
-- Batch enrichment with aggregation
find [
category,
count(*) as total
]
from (
find [
ai_query("openai", "gpt-4o-mini", concat("Categorize as: food, service, ambiance, or other: ", review_text)) as category
]
from reviews
) as categorized
group by category
order by total desc
 
-- Cross-database with AI
find [
u.name,
o.total,
ai_query("openai", concat("Write a thank-you note for ", u.name, " who spent $", o.total)) as note
]
from users@prod-pg as u
join orders@analytics-mysql as o on u.id = o.user_id
where o.total > 500

Error Handling

Failed AI calls never abort the query. Instead, the affected cell contains a descriptive error marker so the rest of your results remain intact.

Error MarkerCause
[AI Error: connection refused]API gateway unreachable
[AI Error: timeout]Request exceeded timeout_secs
[AI Error: HTTP 401]Invalid API key or auth failure
[AI Error: HTTP 429]Rate limit exceeded
[AI Error: empty response]API returned no content
[AI Error: invalid response format]Unparseable response JSON

Performance

AI calls execute concurrently with a configurable semaphore (default: 10 concurrent requests). Tune concurrency in your config to balance throughput against API rate limits.

If the prompt evaluates to NULL (e.g., a referenced column is NULL), the AI call is skipped and the cell is NULL — avoiding unnecessary API calls.

Security

API keys are never exposed in logs or query results — they are redacted in all display output. Use $>${ENV_VAR} syntax in river.yaml to avoid hardcoding secrets. AI configs are loaded separately from database configs, keeping credentials compartmentalized.

TIP
Start with a small limit when testing AI queries to avoid unexpected API usage. Models default to temperature 0.0 for deterministic, reproducible results.

Your Progress

Mark sections complete to earn XP and unlock badges.

Achievements

0/9 unlocked
First Query
~
River Explorer
Join Master
#
Code Collector
Scholar
Grandmaster
Window Wizard
3-Day Streak
🤖
AI Ready

1

Level

0

Total XP

0d

Streak

Quick Reference

The essentials at a glance.

Clause Order

find [columns] from table[@conn]
where condition
group by columns
having condition
window name as (spec)
order by col [asc|desc]
limit N offset M

Cardinal Rules

  • 1.Every query starts with find
  • 2.Strict clause order — no exceptions
  • 3.Always use where with update/remove
  • 4.@connection enables cross-DB queries
  • 5.Use explain before expensive queries

Operators

=Equal
!= / <>Not equal
> >= < <=Comparison
+ - * / %Arithmetic
||String concat
::Type cast
>>Persist results
@Connection ref

Aggregate Functions

count(*)All rows
count(expr)Non-NULL values
count_distinct(expr)Unique values
sum(expr)Sum
avg(expr)Average
min(expr)Minimum
max(expr)Maximum

Write once, query anywhere.