Database
PostgreSQL schema, Drizzle ORM, and migrations - the foundation for Zero sync
Takeout uses PostgreSQL as the source of truth, with Drizzle ORM for schema management. Zero replicates from Postgres to provide real-time sync to clients. This page covers the database layer—for client-side queries and mutations, see the Zero page.
Architecture
PostgreSQL holds two schemas: schema-private for auth tables that stay server-side only, and schema-public for application data that syncs to Zero clients. Zero replicates from the public schema to give clients access with real-time updates.
Schema Files
The database schema is split into two files in src/database/:
schema-public.ts
Tables that sync to Zero clients. This is your application data:
schema-private.ts
Authentication and sensitive data that stays server-side only:
Both schemas feed into Drizzle migrations—“private” means the data doesn’t sync to clients, not that it’s excluded from migrations.
Migrations
Takeout has a unified migration system that combines Drizzle schema migrations with custom TypeScript migrations.
Workflow
- Edit
src/database/schema-public.tsorschema-private.ts - Run migrations:
That’s it. CI handles building and deploying migrations automatically.
Custom Migrations
For data migrations or changes that can’t be expressed in schema:
This creates a TypeScript migration file:
We don’t do down migrations—only forward.
How It Works
bun migrate build does three things:
- Runs
drizzle-kit generateto create SQL from schema changes - Wraps SQL migrations in TypeScript (using
?rawimports) - Bundles everything into a single deployable file
The migration runner tracks applied migrations in a migrations table and runs
them in a transaction.
Server-Side Queries
For server-side code (API routes, server actions), use Drizzle ORM:
See the Drizzle ORM docs for the full query API.
For client-side queries that sync in real-time, use Zero’s ZQL—see the Zero page and Zero’s Reading Data docs.
Aggregates and Triggers
Zero doesn’t support aggregates, but even if it did, computing aggregates on
read is slow for any database. Queries like
SELECT COUNT(*) FROM comments WHERE postId = ? seem innocent but become
problematic at scale—they lock rows, block writes, and get slower as data grows.
The solution is denormalization: pre-computing and storing derived values
alongside your source data. Instead of counting comments on every read, we store
post.commentCount and update it via triggers whenever comments change. This
trades a bit of write complexity for dramatically faster reads.
This is a well-established pattern used by most high-traffic applications. For
example, post.commentCount is updated automatically when comments are added or
removed.
Simple Counter Pattern
Key patterns:
- Recalculate the count from scratch (safer than incrementing)
- Check parent exists before updating (prevents cascade delete errors)
- Use AFTER triggers to ensure the source data is committed
For complex aggregates like time-windowed stats or multi-table relationships, create dedicated stats tables with their own triggers.
Local Development
Starting the Database
Connecting
Or use a GUI like TablePlus with the connection string from ZERO_UPSTREAM_DB
(default: postgresql://user:password@127.0.0.1:5433/postgres).
Troubleshooting
Migration file is blank/empty
Drizzle found no schema changes. Check that:
- Your changes are saved
- You’re editing the right file (
schema-public.tsvsschema-private.ts) - The schema doesn’t already match the database
Check which migrations have run
Migration failed
Migrations run in a transaction, so failures should rollback automatically. If you need to manually fix:
- Check what migrations have run (query above)
- If a bad migration was applied, write a new migration to fix it
- If stuck in a partial state, you may need to manually delete from the migrations table:
Then fix your schema and run migrations again.
Schema changes not detected
- Both
schema-public.tsandschema-private.tsgo to Drizzle - “Private” means it doesn’t sync to Zero clients, not “excluded from migrations”
- Run
bun migrate runwhich regenerates migrations fresh
Zero not seeing changes
After schema changes:
- Run
bun migrate runto update Postgres - Run
bun tko zero generateto regenerate Zero types - Restart the Zero server
Zero replicates from Postgres, so the database must be updated first.
Learn More
- Zero page - Client-side queries and mutations
- Drizzle ORM docs - Schema and query reference
- PostgreSQL docs
Edit this page on GitHub.