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.
Installation
Get River up and running on your machine. Choose the method that fits your workflow.
Quick Install (Recommended)
The fastest way to install River is with a single command. Works on Linux and macOS.
curl -fsSL https://raw.githubusercontent.com/bryanbill/river/main/install.sh | bashWindows users can install via PowerShell.
irm https://raw.githubusercontent.com/bryanbill/river/main/install.ps1 | iexManual Install
Download the latest binary for your platform from the releases page.
Build from Source
Clone the repository and build with Cargo.
git clone https://github.com/bryanbill/river.gitcd rivercargo build --release./target/release/river --helpQuery Basics — to write your first River query.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.
- 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" -) 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.
river --config /path/to/production.yamlConnection Fields
| Field | Required | Description |
|---|---|---|
| name | yes | Connection name — used with @name syntax in queries |
| kind | yes | Database kind: postgres, mysql, mssql, sqlite, mongodb, ai |
| uri | yes | Connection string for the database |
| api_key | no | API key for external services (e.g. AI providers) |
| model | no | Model name for AI providers |
| schema | no | Default 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.
# Run a script and print results as a tableriver 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.csvriver users.rql --out users.xlsxriver users.rql --out users.jsonriver users.rql --out users.txtriver users.rql --out users.xmlScript File Format
A .rql file is plain text containing RiverQL statements. Separate multiple statements with semicolons — just like in the TUI.
-- users.rql:threshold = 1000;find [name, email, total] from userswhere total > :thresholdorder by total desclimit 20; describe users;show tables.rql scripts reuse the exact same engine pipeline (parser, planner, translator, executor) as the TUI and MCP server — behavior is identical across all modes.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.
find usersEquivalent to SELECT * FROM users in standard SQL.
Column Selection
Use square brackets to pick specific columns instead of fetching everything.
find [name, email] from usersfind [name, email, department] from userswhere status = "active"Filtering with where
Filter rows using where. Chain conditions with and / or.
find users where status = "active"find users where status = "active" and age > 21Sorting with order by
Sort results ascending or descending with order by.
find [name, salary] from usersorder by salary descfind [name, department, salary] from employeesorder by department asc, salary descLimiting & Pagination
Control result count with limit and paginate using offset.
find users limit 10find [name, email] from usersorder by created_at desclimit 20 offset 40find → from → where → order by → limit → offsetDISTINCT — 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.
-- Returns unique department names (not one row per employee)find distinct [department] from employees -- DISTINCT with multiple columns — entire row must matchfind distinct [department, status] from employees -- DISTINCT composes with WHERE, ORDER BY, LIMITfind distinct [department] from employeeswhere salary > 50000order by department asclimit 10distinct 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).Expressions & Operators
RiverQL supports a rich set of operators for comparisons, logic, pattern matching, arithmetic, type casting, and time intervals.
Comparison Operators
| Operator | Meaning | Example |
|---|---|---|
| = | Equal | status = "active" |
| != | Not equal | status != "banned" |
| <> | Not equal (alt) | status <> "inactive" |
| > | Greater than | age > 21 |
| >= | Greater or equal | salary >= 75000 |
| < | Less than | price < 100 |
| <= | Less or equal | total <= 50.00 |
find users where age > 21find users where salary >= 75000find products where price < 100Logical Operators
Combine conditions with and, or, and not. Use parentheses to control precedence.
find users where status = "active" and department = "Engineering"find users where department = "Sales" or department = "Marketing"find users where not status = "banned"find userswhere (department = "Sales" or department = "Marketing") and salary > 60000NULL Handling
find users where deleted_at is nullfind users where email_verified_at is not null-- coalesce: first non-null valuefind [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 shorthandfind [name, ifnull(phone, "N/A") as contact] from usersBETWEEN / IN / NOT IN
find [name, created_at] from userswhere created_at between "2024-01-01" and "2024-12-31" find products where price between 10 and 50find 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.
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-insensitiveArithmetic & String Concat
find [name, price * 1.1 as price_with_tax] from productsfind [first || " " || last as full_name] from usersfind [abs(amount), round(price, 2), ceil(score), floor(score)] from productsType Casting
-- Function stylefind [name, cast(age as string) as age_str] from users -- Shorthand operator ::find [name, created_at::string as date_str] from usersstring, integer, float, boolean, datetime, jsonJoins
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.
find [u.name, o.total]from users as ujoin orders as o on u.id = o.user_idLeft Join
Returns all rows from the left table, with matched rows from the right (or NULL if no match).
find [u.name, o.total]from users as uleft join orders as o on u.id = o.user_idRight Join
Returns all rows from the right table, with matched rows from the left.
find [u.name, o.total]from users as uright join orders as o on u.id = o.user_idFull Join
Returns all rows from both tables, with NULLs where there is no match on either side.
find [u.name, o.total]from users as ufull join orders as o on u.id = o.user_idCross Join
Returns the Cartesian product of both tables. No ON clause needed. Use with caution on large tables.
find [u.name, p.name as product]from users as ucross join products as plimit 100limit.Multiple Joins
Chain joins to combine three or more tables in a single query.
find [u.name, o.total, p.name as product]from users as ujoin orders as o on u.id = o.user_idjoin order_items as oi on o.id = oi.order_idjoin products as p on oi.product_id = p.idSelf Join
Join a table to itself using different aliases — great for hierarchical data like org charts.
find [e.name, m.name as manager]from employees as eleft join employees as m on e.manager_id = m.idJoins with Filters
find [u.name, o.total, o.status]from users as ujoin orders as o on u.id = o.user_idwhere o.status = "paid" and o.total > 100order by o.total desclimit 20Join Type Reference
| Type | Keyword | Returns |
|---|---|---|
| Inner | join / inner join | Matching rows from both tables |
| Left | left join | All left rows + matched right (NULL if none) |
| Right | right join | All right rows + matched left (NULL if none) |
| Full | full join | All rows from both, NULLs for no match |
| Cross | cross join | Cartesian product (no ON clause) |
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
| Function | Description |
|---|---|
| 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 |
find [count(*)] from usersfind [sum(total)] from ordersfind [avg(salary)] from employeesfind [min(price), max(price)] from productsMultiple Aggregates
find [ count(*) as total_orders, sum(total) as revenue, avg(total) as avg_order, min(total) as smallest, max(total) as largest]from orderswhere status = "paid"GROUP BY
Group rows by one or more columns, then apply aggregates to each group.
find [department, count(*) as headcount]from employeesgroup by departmentfind [department, status, count(*) as cnt]from employeesgroup by department, statusGROUP BY with Full Projection
find [ category, count(*) as product_count, avg(price) as avg_price, min(price) as cheapest, max(price) as most_expensive]from productsgroup by categoryorder by product_count descHAVING — Filter Groups
having filters groups after aggregation. Unlike where, it operates on aggregate values.
find [user_id, count(*) as order_count]from ordersgroup by user_idhaving count(*) > 5having filters groups after aggregation.
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
function() over (partition by col order by col)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.
find [ name, department, salary, row_number() over (partition by department order by salary desc) as rank]from employeesRANK and DENSE_RANK
rank() leaves gaps after ties. dense_rank() does not.
-- 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 playersLAG and LEAD
Access values from previous or next rows within the partition.
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 changefind [ date, revenue, revenue - lag(revenue, 1) over (order by date) as daily_change]from daily_statsRunning Totals
find [ date, amount, sum(amount) over (order by date) as running_total]from transactionsAggregates Over Windows
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 employeesNamed Windows
Define a reusable window spec with window to avoid repetition.
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 employeeswindow w as (partition by department)Top N Per Group
-- Top 3 highest-paid employees per departmentfind * from ( find [ name, department, salary, row_number() over (partition by department order by salary desc) as rn ] from employees) as rankedwhere rn <= 3Window Function Reference
| Function | Description |
|---|---|
| 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 |
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.
with active_users as ( find * from users where status = "active")find [name, email] from active_userswith 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 ujoin user_totals as ut on u.id = ut.user_idwhere ut.revenue > 10order by ut.revenue descRecursive CTEs
Traverse hierarchical data like org charts and category trees using with recursive.
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_treeSubqueries
-- Scalar subquery in WHEREfind [name, salary] from userswhere salary > ( find [avg(salary)] from users)-- IN subqueryfind [name, department] from employeeswhere department in ( find distinct [department] from departments where budget > 100000)-- EXISTS / NOT EXISTSfind [name] from users as uwhere exists ( find [1] from orders as o where o.user_id = u.id) -- Find users with no ordersfind [name] from users as uwhere not exists ( find [1] from orders as o where o.user_id = u.id)-- Derived table (subquery in FROM)find * from ( find [user_id, sum(total) as revenue] from orders group by user_id) as user_revenuewhere revenue > 500Set 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 (deduplicates — slowest)find [name, email] from customersunionfind [name, email] from suppliers -- UNION ALL (keeps duplicates — fastest)find [name, email] from customersunion allfind [name, email] from suppliersunion 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.
:status = "active" -- Persist active usersfind [name, email, created_at] from users@mongowhere status = :status >> active_users@mysql; -- Persist inactive usersfind [name, email, created_at] from users@mongowhere status != :status >> inactive_users@mysql; -- Combine both groupsfind distinct [email, name] from active_users@mysqlunion allfind distinct [email, name] from inactive_users@mysqlSet Operations with ORDER BY / LIMIT
When a set operation has order by or limit, those clauses apply to the final combined result.
find [name, email] from customers where country = "US"union allfind [name, email] from suppliers where country = "US"order by name asclimit 50Set Operations — Chaining
Set operations are left-associative — you can chain multiple queries together. Each pair is processed left-to-right.
find [name] from employeesunionfind [name] from contractorsunion allfind [name] from internsSet Operations — INTERSECT & EXCEPT
intersect returns rows present in both queries.except returns rows from the first query that are not in the second.
-- INTERSECT (rows in both)find [user_id] from orders where year = 2024intersectfind [user_id] from orders where year = 2025 -- EXCEPT (rows only in first)find [user_id] from usersexceptfind [user_id] from orders where created_at > now() - 30dintersect all or except all variants.DISTINCT
Remove duplicate rows from a single query result. Place distinct right after find and before the column list.
-- Get unique departments (may return 5 rows from 1,000 employees)find distinct [department] from employees -- DISTINCT with multiple columns — deduplication on the full rowfind distinct [department, status] from employees -- DISTINCT with WHERE, ORDER BY, LIMITfind distinct [department] from employeeswhere salary > 50000order by department asclimit 10distinct 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.
-- Deduplicate within each set, then union-all (no global dedup)find distinct [email] from usersunion allfind distinct [email] from newsletter_subscribersSet Operations — Quick Reference
| Operation | Behavior | Use Case |
|---|---|---|
| distinct | Remove duplicates within a single query | List unique departments |
| union | Combine two queries, remove duplicates | Merge customer + supplier lists |
| union all | Combine two queries, keep all rows | Fastest combine (no dedup pass) |
| intersect | Rows common to both queries | Find overlap between two data sets |
| except | Rows in first but not second | Find rows missing from the second set |
CASE Expressions
-- Searched CASEfind [ 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 CASEfind [ name, case status when "active" then "Active User" when "suspended" then "Suspended" else "Unknown" end as status_label]from users-- CASE in ORDER BY for custom sort priorityfind [name, priority] from tasksorder by case priority when "urgent" then 1 when "high" then 2 when "normal" then 3 else 4endCross-Database Queries
Append @connection to any table name to query across different database systems.
-- Join PostgreSQL users with MySQL ordersfind [u.name, o.total]from users@pg as ujoin orders@mysql as o on u.id = o.user_idwhere o.status = "paid"-- Cross-database CTEwith 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_usersjoin mongo_logs on pg_users.id = mongo_logs.user_idwhere mongo_logs.action = "login"group by pg_users.nameorder by login_count desclimit 10where filters before cross-database joins to minimize data transfer. Index join columns on both sides.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
-- Single rowcreate users { name: "Alice", email: "alice@example.com", age: 30 } -- Multiple rowscreate users [ { name: "Alice", email: "alice@example.com" }, { name: "Bob", email: "bob@example.com" }, { name: "Carol", email: "carol@example.com" }] -- Insert from querycreate active_users_backup ( find * from users where status = "active") -- Target a specific connectioncreate 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.
-- Basic updateupdate usersset status = "inactive", updated_at = now()where last_login < now() - 90d -- Update with expressionsupdate productsset price = price * 1.1where category = "premium" -- Target a specific connectionupdate users@pgset status = "verified"where email_verified_at is not nullwhere, the update applies to ALL rows in the table.DELETE — remove
-- Basic deleteremove users where status = "banned" -- Delete with subqueryremove userswhere id not in ( find distinct [user_id] from orders where created_at > now() - 365d) -- Delete on a specific connectionremove logs@mongowhere timestamp < now() - 30dwhere, all rows are deleted. Always use where unless you intend a full-table deletion.CREATE TABLE
-- Basic tablecreate table products ( name string, price float, category string default "general", created_at datetime) -- With primary key and constraintscreate 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 users add column bio stringalter table users add column tier string not null default "free"alter table users drop column temp_dataalter table users alter column age type floatalter table users alter column status type string not null default "active"alter table users alter column status drop defaultalter table users rename column name to full_namealter table users rename to customersalter table users@pg add column notes stringDROP TABLE
-- Basic dropdrop 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 schemadrop table if exists archive.logs@pg cascadeCREATE DATABASE
Create a new database on a connected server with optional if not exists guard.
-- Basic createcreate database analytics -- Idempotent (if not exists)create database if not exists analytics -- On a specific connectioncreate database analytics@pgcreate database reports@mysql.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.
-- Basic dropdrop database analytics -- Safe for scriptsdrop database if exists temp_logs -- On a specific connectiondrop database analytics@pgdrop database reports@mysqlSQLite 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.
-- Simple persistfind * from users >> user_backup -- With conflict handling (upsert)find [user_id, sum(total) as revenue]from ordersgroup by user_id>> user_revenue@pginsert if exists on conflict replace -- Ignore duplicatesfind distinct [email] from new_signups>> verified_emailsinsert if exists on conflict ignoreOperations Reference
| Operation | River | SQL Equivalent |
|---|---|---|
| Insert one | create t { ... } | INSERT INTO t VALUES (...) |
| Insert many | create t [{ ... }, { ... }] | INSERT INTO t VALUES (...), (...) |
| Insert from query | create t (find ...) | INSERT INTO t SELECT ... |
| Persist results | find ... >> target | CREATE TABLE AS + INSERT |
| Upsert | >> target insert if exists on conflict replace | ON CONFLICT DO UPDATE |
| Update | update t set ... where ... | UPDATE t SET ... WHERE ... |
| Delete | remove t where ... | DELETE FROM t WHERE ... |
| Create table | create table t (...) | CREATE TABLE t (...) |
| Alter table | alter table t [add|drop|alter|rename] | ALTER TABLE t ... |
| Drop table | drop table [if exists] t [cascade|restrict] | DROP TABLE [IF EXISTS] t [CASCADE|RESTRICT] |
| Create database | create database [if not exists] name | CREATE DATABASE [IF NOT EXISTS] name |
| Drop database | drop database [if exists] name | DROP DATABASE [IF EXISTS] name |
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 users -- Target a specific connectiondescribe users@pgdescribe orders@mysqldescribe inventory.products@pgSHOW TABLES
List all tables (or collections) in the current or specified connection.
show tables -- For a specific connectionshow tables @pgshow tables @mongoshow tables @mysqlEXPLAIN
View the execution plan without running the query. Essential before executing expensive operations.
explain find [name] from userswhere department = "Engineering"order by salary desc-- Cross-database explainexplain find [u.name, o.total]from users@pg as ujoin orders@mysql as o on u.id = o.user_idexplain 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.
:start_date = "2024-01-01":end_date = "2024-12-31":min_amount = 100 find [name, total, created_at] from orderswhere created_at between :start_date and :end_date and total > :min_amountComments
-- This is a single-line commentfind 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.
:threshold = 1000;find [name, total] from orders where total > :threshold;describe ordersThe Cardinal Rules
| Rule | Description |
|---|---|
| 1. Every query starts with find | find is the universal SELECT keyword |
| 2. Strict clause order | find → from → where → group by → having → order by → limit → offset |
| 3. Always use where with update/remove | Omitting where affects all rows |
| 4. @connection for cross-database | Append @name to route queries to specific databases |
| 5. explain before expensive queries | Understand the plan before executing it |
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.
river --serverriver --server --config /path/to/river.yamlAvailable Tools
The MCP server exposes six tools that AI agents can use to interact with databases.
| Tool | Description |
|---|---|
| river_query | Execute any River query (SELECT, INSERT, UPDATE, DELETE, DDL) |
| river_describe | Describe a table's schema (columns, types, nullability) |
| river_list_tables | List all tables/collections on a connection |
| river_explain | Get the native SQL/MQL equivalent of a River query |
| river_list_connections | List all configured database connections |
| river_help | Return River syntax reference by topic |
Available Resources
Pre-built documentation resources the agent can read without executing queries.
| URI | Content |
|---|---|
| river://docs | Full RiverQL language reference |
| river://docs/quickref | Quick reference: keywords, operators, functions |
| river://docs/keywords | Keyword-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.
{ "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.
{ "mcpServers": { "river": { "command": "river", "args": ["--server", "--config", "/path/to/river.yaml"] } }}Cursor
Add River to .cursor/mcp.json in your project root.
{ "mcpServers": { "river": { "command": "river", "args": ["--server", "--config", "/path/to/river.yaml"] } }}opencode
Add River to ~/.config/opencode/opencode.jsonc.
{ "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.
{ "mcp.servers": { "river": { "command": "river", "args": ["--server", "--config", "/path/to/river.yaml"] } }}river is not on your $PATH, use the absolute path (e.g. ./target/release/river). Build first with cargo build --release.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.
Syntax
Two forms: with default model from config, or with a model override for the query.
ai_query(config_name, prompt)ai_query(config_name, model, prompt)| Argument | Description |
|---|---|
| config_name | AI connection name defined in river.yaml |
| model | (optional) Model name override |
| prompt | Expression evaluated per row — use concat(), column refs, literals |
Basic Examples
-- Summarize reviewsfind [review_text, ai_query("openai", concat("Summarize: ", review_text)) as summary]from reviews limit 5 -- Classify with a specific modelfind [ title, ai_query("openai", "gpt-4o-mini", concat("Classify as bug, feature, or question: ", title)) as category]from support_ticketswhere created_at > now() - 7d -- Translate textfind [ message, ai_query("gemini", "gemini-2.0-flash", concat("Translate to French: ", message)) as french]from chat_messagesSupported Providers
| Provider | Description |
|---|---|
| openai | OpenAI-compatible protocol — covers OpenAI, Deepseek, Kimi, Ollama, vLLM, Groq, Together, Mistral, Perplexity, xAI |
| anthropic | Anthropic Claude — native Messages API (Sonnet, Haiku, Opus) |
| gemini | Google Gemini — native generateContent API (AI Studio and Vertex AI) |
Configuration
AI connections are defined in river.yaml alongside database connections, using kind: ai.
# 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"| Field | Required | Default | Description |
|---|---|---|---|
| name | yes | — | Connection name used in ai_query |
| kind | yes | — | Must be ai |
| provider | no | openai | openai, anthropic, or gemini |
| uri | yes | — | API base URL |
| api_key | yes | — | API key (supports ${ENV_VAR}) |
| model | yes | — | Default model name |
| headers | no | {} | Additional HTTP headers |
| concurrency | no | 10 | Max parallel AI calls |
| timeout_secs | no | 60 | HTTP request timeout |
| max_tokens | no | 1024 | Max response tokens |
| temperature | no | 0.0 | Model temperature |
Advanced Patterns
Combine AI functions with CTEs, subqueries, and cross-database joins for powerful data pipelines.
-- AI-powered filtering with CTEwith 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 enrichedwhere is_complaint = "yes" -- Batch enrichment with aggregationfind [ 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 categorizedgroup by categoryorder by total desc -- Cross-database with AIfind [ 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 ujoin orders@analytics-mysql as o on u.id = o.user_idwhere o.total > 500Error 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 Marker | Cause |
|---|---|
| [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.
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 unlocked1
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 MCardinal 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
Aggregate Functions
Write once, query anywhere.