Migrations

Turbine includes a small runner for data migrations: backfills, seed data and attribute cleanups. Migrations are files in a directory, run in filename order, and every applied migration is recorded as an item in your table.

Setup

Create a script that defines your migrations and hands the command line to cli():

// scripts/migrate.ts
import { defineMigrations } from "dynamodb-turbine";

import { table } from "../src/table";

defineMigrations({ table, directory: "./migrations" }).cli();

Run it with tsx so TypeScript migration files can be loaded:

npx tsx scripts/migrate.ts                 # run all pending migrations
npx tsx scripts/migrate.ts down            # roll back the last applied migration
npx tsx scripts/migrate.ts down 2026-01-15-add-user-type
npx tsx scripts/migrate.ts redo            # roll back and re-apply the last one
npx tsx scripts/migrate.ts new "add user type"

new creates a date-prefixed file such as migrations/2026-01-15-add-user-type.ts.

Writing migrations

A migration default-exports a class extending Migration:

import { Migration } from "dynamodb-turbine";

import { table } from "../src/table";

export default class AddUserType extends Migration {
  async up() {
    await this.forEachItem(table, { index: "type-sk", type: "user" }, (item) =>
      table.update({
        Key: { pk: item.pk, sk: item.sk },
        UpdateExpression: "SET #userType = :userType",
        ExpressionAttributeNames: { "#userType": "userType" },
        ExpressionAttributeValues: { ":userType": "member" },
      }),
    );
  }

  async down() {
    // revert the change
  }
}

The Migration base class provides:

MemberDescription
forEachItem(table, key, callback, { concurrency })Pages through all raw items matching the key condition and runs the callback for each, up to concurrency (default 25) at a time. Returns the number of items scanned.
isConditionalCheckFailure(error)Whether an error is a ConditionalCheckFailedException.
alwaysRunSet to true to run the migration on every up, even when it has been applied before. It must then be idempotent.

The key for forEachItem uses the same syntax as entity queries: an optional index name plus key conditions such as { sk: { beginsWith: "user#" } }.

Options

PropertyTypeDefaultDescription
tableTableThe table to record applied migrations in
directorystring"./migrations"Where migration files live (.ts and .js)
schemaZodObject{ id, appliedAt }Schema for the migration records
keysobjectsee belowKey definitions for the migration records

By default, a migration is stored with your table's hash key set to system-migration#<filename> and its range key (if any) set to system-migration.

Custom schema and keys

Pass schema and keys to change how applied migrations are stored, for example to record who ran them or to make them show up in a GSI:

defineMigrations({
  table,
  schema: z.object({
    id: z.string(),
    appliedAt: z.iso.datetime(),
    appliedBy: z.string().default(() => process.env.USER ?? "unknown"),
  }),
  keys: {
    pk: (migration) => ["migration", migration.id],
    sk: "migration",
    type: "migration",
  },
});

The schema must include an id field, which holds the migration's filename. The runner sets appliedAt when it records a migration; any other fields need a default. The table keys must be derivable from id alone, because that is all the runner has when it looks a migration up.

Programmatic use

Besides cli(), the object returned by defineMigrations exposes each command directly, which is useful in deploy scripts or tests:

const migrations = defineMigrations({ table });

await migrations.up();
await migrations.down("2026-01-15-add-user-type");
await migrations.redo();
await migrations.create("backfill emails");

These throw on failure, where cli() prints the error and sets a non-zero exit code.