Zero Query Language (ZQL) is a type-safe, composable query builder that generates efficient SQL. Queries are reactive: when data changes, your UI updates automatically.
Quick Start
import { Zero } from '@rocicorp/zero' ;
import { schema } from './schema' ;
const zero = new Zero ({ server , schema , userID });
// Simple query
const openIssues = zero . query . issue
. where ( 'status' , 'open' )
. orderBy ( 'created' , 'desc' )
. limit ( 10 );
// Use in React
import { useQuery } from '@rocicorp/zero-react' ;
function IssueList () {
const issues = useQuery ( openIssues );
return (
< ul >
{ issues . map ( issue => (
< li key = {issue. id } > {issue. title } </ li >
))}
</ ul >
);
}
Query Builder
Queries are built using method chaining:
const query = zero . query . issue // Start with table
. where ( 'status' , 'open' ) // Add filter
. related ( 'creator' ) // Load relationship
. orderBy ( 'created' , 'desc' ) // Sort
. limit ( 10 ); // Limit results
Starting a Query
// From Zero instance
const issues = zero . query . issue ;
const users = zero . query . user ;
// Or from builder
import { createBuilder } from '@rocicorp/zero' ;
const builder = createBuilder ( schema );
const issues = builder . issue ;
Type Safety
ZQL is fully type-safe:
// ✅ Type-safe: 'status' exists and has correct type
zero . query . issue . where ( 'status' , 'open' );
// ❌ Type error: 'nonexistent' is not a valid column
zero . query . issue . where ( 'nonexistent' , 'open' );
// ❌ Type error: value type must match column type
zero . query . issue . where ( 'status' , 42 );
Filtering with where
Simple Conditions
// Equality (shorthand)
zero . query . issue . where ( 'status' , 'open' )
// Explicit operator
zero . query . issue . where ( 'priority' , '>' , 5 )
// Multiple conditions (AND)
zero . query . issue
. where ( 'status' , 'open' )
. where ( 'priority' , '>' , 3 )
. where ( 'assigneeID' , user . id )
Supported Operators
Equality comparison: . where ( 'status' , '=' , 'open' )
. where ( 'status' , 'open' ) // Shorthand
Inequality comparison: . where ( 'status' , '!=' , 'closed' )
Greater than: . where ( 'priority' , '>' , 5 )
Greater than or equal: . where ( 'priority' , '>=' , 5 )
Less than: . where ( 'created' , '<' , Date . now ())
Less than or equal: . where ( 'priority' , '<=' , 10 )
Match any value in array: . where ( 'status' , 'IN' , [ 'open' , 'in_progress' ])
Match none of the values: . where ( 'status' , 'NOT IN' , [ 'closed' , 'archived' ])
Check for null: . where ( 'assigneeID' , 'IS' , null )
Check for non-null: . where ( 'assigneeID' , 'IS NOT' , null )
Pattern matching: . where ( 'title' , 'LIKE' , '%bug%' )
Escape special characters: import { escapeLike } from '@rocicorp/zero' ;
. where ( 'title' , 'LIKE' , `% ${ escapeLike ( userInput ) } %` )
Expression Builder
For complex conditions, use the expression builder:
zero . query . issue . where (
({ cmp , or , and }) => or (
cmp ( 'status' , '=' , 'open' ),
and (
cmp ( 'status' , '=' , 'closed' ),
cmp ( 'priority' , '>' , 8 )
)
)
)
// Equivalent SQL:
// WHERE status = 'open' OR (status = 'closed' AND priority > 8)
Expression builder functions:
cmp(field, op, value): Compare field to value
and(...conditions): Combine with AND
or(...conditions): Combine with OR
Comparing to Null
Use IS and IS NOT for null checks, not = or !=.
// ✅ Correct: Use IS for null
zero . query . issue . where ( 'assigneeID' , 'IS' , null )
// ❌ Wrong: = with null doesn't work as expected in SQL
zero . query . issue . where ( 'assigneeID' , '=' , null )
// ✅ Correct: IS NOT for non-null
zero . query . issue . where ( 'assigneeID' , 'IS NOT' , null )
In SQL, NULL = NULL is NULL (not true), so you must use IS NULL for null checks.
Loading Relationships
// Load one-to-one relationship
const issuesWithCreators = zero . query . issue
. related ( 'creator' );
// Result type:
// Array<{
// id: string;
// title: string;
// creator: {id: string; name: string} | undefined;
// }>
// Load one-to-many relationship
const issuesWithComments = zero . query . issue
. related ( 'comments' );
// Result type:
// Array<{
// id: string;
// title: string;
// comments: Array<{id: string; body: string}>;
// }>
// Load only recent comments
const issues = zero . query . issue
. related ( 'comments' , ( q ) =>
q . where ( 'created' , '>' , Date . now () - 86400000 )
. orderBy ( 'created' , 'desc' )
. limit ( 5 )
);
Multiple Relationships
const issues = zero . query . issue
. related ( 'creator' )
. related ( 'assignee' )
. related ( 'comments' , ( q ) => q . limit ( 3 ))
. related ( 'labels' );
// Result type:
// Array<{
// id: string;
// title: string;
// creator: User | undefined;
// assignee: User | undefined;
// comments: Comment[];
// labels: Label[];
// }>
Junction Relationships
For many-to-many relationships:
// Schema defines a many-to-many through issueLabel junction
const issueRelationships = relationships ( issue , ({ many }) => ({
labels: many (
{ sourceField: [ 'id' ], destField: [ 'issueID' ], destSchema: issueLabel },
{ sourceField: [ 'labelID' ], destField: [ 'id' ], destSchema: label },
),
}));
// Query automatically handles the junction
const issues = zero . query . issue . related ( 'labels' );
// Zero transparently joins through issueLabel
Use whereExists to filter by related data:
// Issues that have comments
const issuesWithComments = zero . query . issue
. whereExists ( 'comments' );
// Issues that have comments from a specific user
const issues = zero . query . issue
. whereExists ( 'comments' , ( q ) =>
q . where ( 'creatorID' , userId )
);
// Negate with NOT EXISTS (not directly supported, use where instead)
// For "issues without comments", use a custom query
Exists Options
type ExistsOptions = {
// Control join direction for performance
flip ?: boolean ;
// Pre-resolve value (experimental)
scalar ?: boolean ;
};
// Force flipped join (child drives)
zero . query . issue . whereExists ( 'comments' , { flip: true });
// Pre-resolve scalar value (experimental)
zero . query . issue . whereExists ( 'creator' , { scalar: true });
Sorting
Order By
// Single column
zero . query . issue . orderBy ( 'created' , 'desc' )
// Multiple columns
zero . query . issue
. orderBy ( 'priority' , 'desc' )
. orderBy ( 'created' , 'desc' )
// Ascending (default)
zero . query . issue . orderBy ( 'title' , 'asc' )
Start From
Paginate using start():
// Start after a specific row
const nextPage = zero . query . issue
. orderBy ( 'created' , 'desc' )
. start ( lastIssue , { inclusive: false })
. limit ( 20 );
// Start at a specific row (inclusive)
const fromIssue = zero . query . issue
. orderBy ( 'created' , 'desc' )
. start ( issue , { inclusive: true })
. limit ( 20 );
The row passed to start() must have been returned by a previous fetch or push.
Limiting Results
Limit
// First 10 results
zero . query . issue
. orderBy ( 'created' , 'desc' )
. limit ( 10 );
One Result
// Get single result or undefined
const issue = zero . query . issue
. where ( 'id' , issueId )
. one ();
// Result type: Issue | undefined (not Issue[])
Executing Queries
In React
import { useQuery } from '@rocicorp/zero-react' ;
function MyComponent () {
const issues = useQuery ( zero . query . issue . where ( 'status' , 'open' ));
// issues updates automatically when data changes
return < div >{issues.map( ... )} </ div > ;
}
One-Time Execution
// Get current results
const issues = await zero . run ( query );
// With options
const issues = await zero . run ( query , {
type: 'complete' , // Wait for server
ttl: 30000 , // Keep data for 30s
});
Run options:
type: 'unknown' (default): Return immediately with cached data
type: 'complete': Wait for fresh data from server
ttl: Time to live in milliseconds
Materialized Views
// Create a live view
const view = zero . materialize ( query , { ttl: 60000 });
// Subscribe to changes
for await ( const snapshot of view ) {
console . log ( 'Data changed:' , snapshot );
}
// Or use TypedView interface
const view = zero . materialize ( query );
const unsubscribe = view . addListener (() => {
console . log ( 'New data:' , view . data );
});
Preloading
// Preload data into cache
const { cleanup , complete } = zero . preload ( query , { ttl: 30000 });
// Wait for data to load
await complete ;
// Later: clean up when no longer needed
cleanup ();
Query Compilation
ZQL queries are compiled to SQL:
// ZQL
zero . query . issue
. where ( 'status' , 'open' )
. related ( 'creator' )
. orderBy ( 'created' , 'desc' )
. limit ( 10 )
// Compiled to SQL (simplified):
SELECT
issue . * ,
json_object ( 'id' , user . id , 'name' , user . name ) as creator
FROM issue
LEFT JOIN user ON user . id = issue . creatorID
WHERE issue . status = 'open'
ORDER BY issue . created DESC
LIMIT 10
AST Representation
Queries are represented as an Abstract Syntax Tree:
// From packages/zql/src/query/query-impl.ts
type AST = {
table : string ;
where ?: Condition ;
orderBy ?: OrderPart [];
limit ?: number ;
relationships ?: Record < string , AST >;
};
type Condition = SimpleCondition | Conjunction | Disjunction ;
type SimpleCondition = {
type : 'simple' ;
op : SimpleOperator ;
left : ColumnReference ;
right : LiteralValue | Parameter ;
};
Incremental View Maintenance
Zero uses IVM to efficiently update query results:
IVM Pipeline
Queries compile to a pipeline of operators:
// From packages/zql/src/ivm/operator.ts
export interface Input {
fetch ( req : FetchRequest ) : Stream < Node | 'yield' >;
setOutput ( output : Output ) : void ;
}
export interface Output {
push ( change : Change , pusher : InputBase ) : Stream < 'yield' >;
}
// Pipeline example:
// Source → Filter → Join → Take → View
Change Propagation
When data changes:
Source receives change from replication
Filter checks if change affects results
Join updates related data if needed
Take adjusts limit if needed
View emits incremental update to clients
// Incremental update (not full re-query)
type Change =
| { type : 'add' , node : Node }
| { type : 'remove' , node : Node }
| { type : 'edit' , node : Node , oldNode : Node }
| { type : 'child' , node : Node , child : { relationshipName : string , change : Change }};
O(changes) not O(data): Work proportional to changes, not dataset size
Shared pipelines : Multiple clients reuse the same IVM pipeline
Fine-grained updates : Only changed rows are synced
Custom Queries
For complex logic, define custom server-side queries:
import { defineQuery } from '@rocicorp/zero' ;
export const issuesByProject = defineQuery (
async ( tx , projectID : string , userID : string ) => {
// Server-side logic with permissions
const project = await tx . query . project
. where ( 'id' , projectID )
. one ()
. run ();
if ( ! project ) {
throw new Error ( 'Project not found' );
}
// Check membership
const member = await tx . query . projectMember
. where ( 'projectID' , projectID )
. where ( 'userID' , userID )
. one ()
. run ();
if ( ! member ) {
throw new Error ( 'Access denied' );
}
return tx . query . issue
. where ( 'projectID' , projectID )
. related ( 'creator' )
. orderBy ( 'created' , 'desc' )
. run ();
}
);
// Client usage
const issues = useQuery (
zero . queries . issuesByProject ,
[ projectId , currentUser . id ]
);
See Custom Queries for more details.
Best Practices
Use indexes for filtered columns
Columns used in where() should have PostgreSQL indexes: CREATE INDEX idx_issue_status ON issue( status );
CREATE INDEX idx_issue_created ON issue(created);
Avoid deeply nested relationships: // ❌ Too deep
zero . query . comment
. related ( 'issue' , q =>
q . related ( 'project' , q =>
q . related ( 'team' , q =>
q . related ( 'members' )
)
)
)
// ✅ Better: Use custom query or multiple queries
Always use `orderBy` with `limit`
// ❌ Unstable results
zero . query . issue . limit ( 10 )
// ✅ Stable, predictable results
zero . query . issue
. orderBy ( 'created' , 'desc' )
. limit ( 10 )
Use `one()` for single results
// ❌ Returns array even though only one result
const [ issue ] = zero . query . issue . where ( 'id' , id ). limit ( 1 );
// ✅ Returns single value or undefined
const issue = zero . query . issue . where ( 'id' , id ). one ();
Filter related data when possible
Troubleshooting
Query Returns Empty Results
Check permissions (if using legacy permissions API)
Verify data exists in PostgreSQL
Check zero-cache logs for SQL errors
Use EXPLAIN ANALYZE on generated SQL
Slow Queries
Add indexes to filtered/ordered columns
Limit relationship depth
Use limit() to reduce result size
Check for N+1 queries (use related() instead of multiple queries)
Type Errors
// ❌ Type error: string not assignable to number
zero . query . issue . where ( 'priority' , 'high' )
// ✅ Correct type
zero . query . issue . where ( 'priority' , 5 )
If types are incorrect, verify your schema matches PostgreSQL.
Next Steps
Schema Define tables and relationships
Sync Model Understand how queries stay in sync
Custom Queries Implement server-side query logic
React Hooks Use queries in React components