← Back to blog

Zero-Downtime Postgres Migrations Without the Manual Steps

Iman RadjaviIman Radjavi·

My co-founder Fabian wrote Reshape, a tool that runs Postgres schema migrations with no downtime by keeping the old and new schema available while a deploy rolls out. He has written about how it works and about why renaming a column is harder than it should be. This post is about how we built it into Specific, where it reaches the parts a standalone tool can't: the local dev environment, the deploy pipeline, and preview environments.

Reshape is one setting on the database. Migrations apply the moment you save the file in development, and every deploy starts, completes, or aborts them in step with the rollout. No commands to run and nothing for a coding agent to forget, in any language. Here is how that works.

Why a migration takes you down

Two things happen when you run ALTER TABLE against a live database.

Locks. Most ALTER TABLE statements take an ACCESS EXCLUSIVE lock. The lock queues behind any running transaction, and every query that arrives after it queues too. A change that would take milliseconds can stall the whole application for as long as the longest transaction ahead of it.

The rollout gap. A deploy is not atomic. For a while, old and new instances serve traffic at the same time. Rename name to full_name and the old instances start failing on every query that touches it. Roll back the code and the schema is still changed.

The established answer is expand and contract: add the new column, deploy code that writes to both, backfill, deploy code that reads from the new one, drop the old one. Those are the five steps, and every one of them has to happen in order, on every environment, with the backfill written to avoid long transactions.

What Reshape does

Reshape is an open-source, zero-downtime migration tool for Postgres. You declare the change in a small TOML file, and it runs the safe sequence for you in three phases:

  1. Start. New columns are added under temporary names, triggers keep old and new columns in sync, existing rows are backfilled, and a new Postgres schema with views over the tables is created. The database now serves two schema versions at once.
  2. Rollout. New code connects with search_path set to the new schema. Old code keeps using the old one. Nothing has been removed, so rolling back means pointing code at the old schema again.
  3. Complete. Once every instance runs the new code, the old columns and triggers are dropped and the new columns take their final names. Abort does the reverse if the rollout fails.

Here is a rename plus type change as one migration:

# migrations/002_price_in_cents.toml
[[actions]]
type = "alter_column"
table = "products"
column = "price"
up = "price * 100"
down = "price / 100"

    [actions.changes]
    name = "price_cents"
    type = "INTEGER"

up computes the new column from the old, down the old from the new, so writes from either version of the code land in both. Fabian's posts cover the mechanism and migrations that span several tables in depth, and pgroll from Xata, which lists Reshape as one of its inspirations, takes the same multi-version approach with JSON migrations.

What none of these tools can do on their own is run the phases at the right moments of a deploy. That still falls to a person, or a CI pipeline someone has to write. That is the part we built.

Making it the default

In Specific, Reshape is a setting on the database:

build "api" {
  base    = "node"
  command = "npm run build"
}

postgres "main" {
  reshape {
    enabled = true
  }
}

service "api" {
  build   = build.api
  command = "node dist/server.js"

  endpoint {
    public = true
  }

  env = {
    PORT         = port
    DATABASE_URL = postgres.main.url
  }

  dev {
    command = "npm run dev"
  }
}

Migrations live in migrations/ next to specific.hcl (or a directory you set with migrations_dir). That is the whole integration from the user's side. Everything else happens inside the lifecycle:

  • The connection string carries the schema. postgres.main.url includes the right search_path for the schema version that code should see. Application code reads DATABASE_URL and never knows migrations exist.
  • Deploys run the phases. specific deploy provisions resources, then runs start for every database with pending migrations before any new code rolls out. Each service rolls out in turn, with its pre_deploy hook, the new instances, a wait for them to become healthy, and post_deploy. If every rollout succeeds, complete removes the old schema. If any rollout fails, abort runs and the database is back where it started, with the old instances never replaced.
  • Previews get the same treatment. A preview environment deploys against its own database branch, so its migrations start and complete on the branch and never touch the parent.
  • Development keeps the last migration open. specific dev completes every migration except the latest, which stays started so you can keep editing it. Edit the file and it's aborted and restarted without restarting your services. Add a new file and the previous one is completed and made read-only, the new one starts, and services restart on the new schema.

There is no command to run in the normal path. The manual ones (specific reshape start, complete, abort, status) exist for when you want direct control.

Why this matters for coding agents

The expand and contract pattern is a runbook: several commands, in order, at particular moments, on every environment. Runbooks are exactly what agents get wrong. They skip a step, run it against the wrong environment, or do it right once and not the second time.

The smallest version of this is the most familiar one. The agent edits the schema, writes the migration file, and then forgets the command that applies it. The app starts, the tests run against a database that still has the old schema, and whatever happens next is debugging a problem that doesn't exist. With Reshape built in there is no apply command to forget: specific dev watches the migrations directory and applies a new or edited migration as soon as the file is saved, so the running app is always on the schema the code expects.

With Reshape built in, the agent's job is to write a TOML file that describes the change. specific check validates specific.hcl and every migration file in one pass, so the agent gets a tight loop: write the migration, run specific check, fix what it reports, run specific dev and watch the schema change under the running app. The open last migration means it can iterate on a change instead of stacking corrective migrations on top of a wrong one. And when it opens a pull request, the preview runs the migration for real on a copy of the data.

The phases, the ordering, and the rollback are structural. The agent cannot forget them because it never does them.

Any language, any ORM

Reshape works at the database level, so none of this depends on your stack. The same migrations/ directory serves a Node API, a Python worker, and a Go service sharing one database, and a project can switch frameworks without touching its migration history. ORMs stay what they are good at, the query layer: point Prisma or Drizzle at DATABASE_URL and let the schema come from Reshape. If a team's workflow is built around an ORM's own migrations, that still works too: run its migrate command in a pre_deploy hook instead of enabling Reshape, as the Prisma and Drizzle sections of the frameworks guide show. You give up the zero-downtime part, and nothing else.

What it doesn't do

Reshape is a schema migration tool, not an ORM. It doesn't generate migrations from a Prisma or Drizzle schema. Data transformations are expressed through up and down expressions or a custom action, and backfilling a very large table still costs the I/O it always did, just without the lock. The multi-version trick depends on search_path, so a client that hardcodes a schema name in every query won't see the new version.

Common questions

Do I have to change my application code?

No. Tables appear under their normal names in each schema version, and the connection string carries the search_path. Code that reads DATABASE_URL works unchanged.

What happens if a deploy fails halfway?

The migration is aborted automatically and the schema rolls back. Because start only adds things, the old code was running against an intact schema the whole time.

Can I keep my ORM's migrations?

Yes. Run them in a pre_deploy hook and leave reshape off. You can switch to Reshape later; it starts from whatever schema the database has.

Can I use Reshape without Specific?

Yes. It's a standalone CLI that works against any Postgres. Specific adds the automatic phase management in specific dev, specific deploy, and previews.

Try it

Start with the Reshape guide and the Postgres guide in our docs.

To start in your own project, give your agent this prompt:

Help me get started with Specific by following: https://docs.specific.dev/for-ai/onboarding

Or install the CLI yourself:

curl -fsSL https://specific.dev/install.sh | sh

Then run specific init, add the reshape block, and ask your agent for the first migration.