21. Schema Migrations
Alex wants schema changes that are versioned, reviewable, and repeatable across environments.
Migrations are versioned SQL files in your project's migrations/ directory, applied in order against your project's database schema. The platform records what has been applied, so migrations up only runs what's pending — re-running it is always safe, locally or in CI. Use migrations for anything the table editor doesn't cover: composite unique constraints, CHECK constraints, partial indexes, triggers, backfills.
The workflow
A realistic example
migrations/0001_create_listings.sql — a table with a composite natural key, a CHECK constraint, a partial index, and an RLS policy:
CREATE TABLE listings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
source text NOT NULL,
source_id text NOT NULL,
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'expired', 'flagged')),
price numeric(8,2),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (source, source_id) -- composite natural key: no dupes per source
);
CREATE INDEX idx_listings_active
ON listings (user_id, created_at)
WHERE status = 'active'; -- partial index: hot rows only
ALTER TABLE listings ENABLE ROW LEVEL SECURITY;
CREATE POLICY listings_owner ON listings
USING (public.is_service_role() OR user_id = public.current_end_user_id())
WITH CHECK (public.is_service_role() OR user_id = public.current_end_user_id());How it executes
- Each migration runs in one transaction — if any statement fails, nothing from that file is applied.
- It runs inside your project schema: use unqualified table names. Multi-statement files are expected.
- It runs as your project’s owner role, which owns the tables you create, so their RLS policies don’t limit the migration itself (a backfill sees every row). A migration has no end user and isn’t the service role:
public.is_service_role()isn’t true andpublic.current_end_user_id()is NULL. Use them in policy definitions, which are checked later on each request, not to make decisions inside the migration. Leave the platform-managed tables (usersand the others) alone in migrations: reference them with foreign keys, but don’t read or change their rows. - A migration request must finish within 30 seconds. Split long backfills into several migrations.
- Applied versions are recorded with a checksum. Re-applying an identical file is a no-op; editing an already-applied file is rejected — add a new version instead.
- There are no down migrations — to undo something, write a new forward migration. For a risky change, write and review that rollback migration before you apply the change.
- Concurrent runs are serialized per project, so two CI jobs can't race each other.
- On Team, migrations run on your project’s dedicated database, the same way.
Verifying a policy change
A migration returns no query results, and catalog queries aren’t supported for your SQL, so verify around it:
- Rehearse on a fresh test project: apply your existing migrations (schema only, no data), then the change, then its rollback.
- Before: save the policies of the affected tables (
GET .../schema/tables/<table>/policies) and the definitions of the functions involved (GET .../schema/functions/<name>). - Apply the migration, fetch the same data again and compare.
- Test behaviour with real callers: a test user’s session, the public key alone, and your server’s secret key. Each call should be allowed or refused as intended (see Row-Level Security).
Running migrations in CI
Authenticate with a Personal Access Token (Account → Tokens) stored as a CI secret. Example GitHub Actions step:
- name: Apply database migrations
run: |
brew install stgime/tap/eurobase # or download the binary
eurobase login --token "$EUROBASE_PAT"
eurobase switch my-project
eurobase migrations up
env:
EUROBASE_PAT: ${{ secrets.EUROBASE_PAT }}Spin up a test project from your schema (no data)
Because migrations are plain SQL files you can apply to any project, they’re the simplest way to stand up a test or staging project with your schema but none of your production data. Create an empty project, point the CLI at it, and apply your migrations:
eurobase projects create floorball-test eurobase switch floorball-test eurobase migrations up
You get the tables, foreign keys, CHECK constraints, RLS policies and functions — with no rows copied. There’s no one-click “schema-only export” yet; on Team / Legal Team you can alternatively run pg_dump --schema-only over your direct connection and load it into the new project.
Applying migrations over the API
The CLI calls a plain HTTP API, which you can use directly (for example from CI) with a personal access token — ideally one scoped to the project, with the Developer role. Each request applies one migration in a single transaction: all of it or nothing.
GET on the same path lists the versions already applied (with their checksums), so a script can skip what’s done, as eurobase migrations up does. Sending an applied version again with the same SQL changes nothing; with different SQL it answers 409 — give the change a new version instead of editing an applied migration.
Checking the schema first. GET /platform/projects/$PROJECT_ID/schema lists your tables with their columns, keys, indexes, triggers and whether RLS is on (with the Developer role, the platform-managed tables too), and GET .../schema/functions lists your SQL and PL/pgSQL functions (.../schema/functions/triggers lists trigger functions). GET .../schema/objects lists views (and whether they use security_invoker), materialized views, sequences and types (enums with their values, composite types, domains, ranges with their multirange type, and types declared with CREATE TYPE name; but not yet defined). The safest conflict check is the migration itself: use plain CREATE without IF NOT EXISTS, so any name that already exists fails the migration and nothing is applied. (A CREATE FUNCTION with different arguments adds an overload instead of failing.) The sample todos table is yours to keep or drop; a migration that doesn’t touch it can ignore it. Its policy lets everyone read and write it, so drop it or tighten it before going live.
What migration SQL can't do (and why)
Migrations run with your project's developer credentials (PAT or console session) — never with API keys: a leaked server key must not be able to alter your schema. Each migration executes under a database role scoped to only your project's schema, so it physically cannot read or write another project or the platform's own tables — that boundary is enforced by Postgres, not just by validation. On top of that, the platform rejects operations that try to reach outside your project before they run:
- References to other schemas (
public.*,pg_catalog, other tenants). Exception: the RLS helperspublic.is_service_role()andpublic.current_end_user_id(). For UUID defaults use the built-ingen_random_uuid()(uuid_generate_v4()isn’t available). - PostgreSQL system catalog objects — any
pg_-prefixed name such aspg_classorpg_policies, qualified or not (a few harmless functions such aspg_typeofare allowed). Your own tables and columns can’t start withpg_either. Same rule as the SQL editor; to inspect your schema, use the schema endpoints in Row-Level Security. GRANT/REVOKE,SET ROLE/search_path,set_config,ALTER DEFAULT PRIVILEGES,OWNER TO— access control belongs to the platform (same rules as the SQL editor).- Transaction control (
COMMIT,BEGIN) — your file already runs in a transaction. SECURITY DEFINERfunctions, rules (CREATE RULE),CREATE EXTENSION,COPY.- Views without
WITH (security_invoker = true), and materialized views: both would skip your RLS policies. This includes dropping and recreating a view: addsecurity_invokerwhen you do (see chapter 12).
Plain LANGUAGE plpgsql functions and triggers are fine. Bulk data loads belong in the SDK/REST data path, not migrations.
Who can use the tables a migration creates
You don’t grant anything. A table created by a migration (or in the SQL editor) can be read and written by your app right away: the roles behind the SDK, REST API, RPC, edge functions and cron get SELECT, INSERT, UPDATE and DELETE on new tables and can use new sequences and call new functions. Which rows a caller may read or change is decided by your RLS policies (a table without RLS is open to all of them), so enable RLS and add policies in the same migration. Migrations can’t run GRANT or REVOKE at all.
- Functions you call via RPC run as the caller (
SECURITY INVOKER, the default), and so do trigger functions. Statements inside them have the same privileges and the same policies as the caller, includingUPDATEandINSERTfrom a trigger. SELECT … FOR UPDATElocks only rows the caller can both read and update: a row has to pass yourSELECTand yourUPDATEpolicies, otherwise it is skipped: not returned and not locked. Without anUPDATEpolicy,FOR UPDATEreturns no rows.- To check the result, call your tables and RPCs as real callers on a test project: a signed-in test user, the public key alone, and the secret key (see CLI → Testing RLS policies).
GET .../schema/rls-auditreturns each table’s RLS status and flags tables without RLS (critical) or without policies (warning).
Making tables read-only during a data migration
To stop every write to a table while you export or move its data (your app’s users, secret-key clients, edge functions, cron jobs, and also your own SQL), add a trigger that refuses writes. Triggers fire for everyone, and only you can remove them. Reads keep working.
CREATE FUNCTION migration_freeze() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'table % is read-only during migration', TG_TABLE_NAME
USING ERRCODE = '25006';
END $$;
-- once per table:
CREATE TRIGGER migration_freeze
BEFORE INSERT OR UPDATE OR DELETE OR TRUNCATE ON legacy_table
FOR EACH STATEMENT EXECUTE FUNCTION migration_freeze();
-- to lift it:
DROP TRIGGER migration_freeze ON legacy_table;
DROP FUNCTION migration_freeze(); While it’s on, REST and RPC writes get 409 with {"error": "table legacy_table is read-only during migration", "code": "25006"}; edge functions and cron jobs get the database error. Pause cron jobs that write to the table first. For a consistent copy of all tables, use the full project export (Compliance): it reads every table from one snapshot.
The same goes for any error your own triggers or functions raise with RAISE EXCEPTION: REST returns 400 with your message and its code (e.g. P0001), as RPC does.