Skip to main content
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

=
operator
Equality comparison:
.where('status', '=', 'open')
.where('status', 'open')  // Shorthand
!=
operator
Inequality comparison:
.where('status', '!=', 'closed')
>
operator
Greater than:
.where('priority', '>', 5)
>=
operator
Greater than or equal:
.where('priority', '>=', 5)
<
operator
Less than:
.where('created', '<', Date.now())
<=
operator
Less than or equal:
.where('priority', '<=', 10)
IN
operator
Match any value in array:
.where('status', 'IN', ['open', 'in_progress'])
NOT IN
operator
Match none of the values:
.where('status', 'NOT IN', ['closed', 'archived'])
IS
operator
Check for null:
.where('assigneeID', 'IS', null)
IS NOT
operator
Check for non-null:
.where('assigneeID', 'IS NOT', null)
LIKE
operator
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:
  1. Source receives change from replication
  2. Filter checks if change affects results
  3. Join updates related data if needed
  4. Take adjusts limit if needed
  5. 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}};

Performance Benefits

  • 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

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
// ❌ Unstable results
zero.query.issue.limit(10)

// ✅ Stable, predictable results
zero.query.issue
  .orderBy('created', 'desc')
  .limit(10)
// ❌ 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();

Troubleshooting

Query Returns Empty Results

  1. Check permissions (if using legacy permissions API)
  2. Verify data exists in PostgreSQL
  3. Check zero-cache logs for SQL errors
  4. Use EXPLAIN ANALYZE on generated SQL

Slow Queries

  1. Add indexes to filtered/ordered columns
  2. Limit relationship depth
  3. Use limit() to reduce result size
  4. 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

Build docs developers (and LLMs) love