Skip to content

Repository files navigation

Spawn

Database Migrations, Testing, and Reproducibility

License Docs

Spawn lets you maintain functions, views, triggers, and other database logic as normal editable source files, while compiling immutable historical migrations from them. It was built to support developers who like to lean heavily on the database, but find existing tooling lacking in support for doing so.

Other tools require you to copy your view/function/etc into a new migration when updating it, and then edit the copy. This results in hard-to-read pull requests. Spawn lets you keep your snippets in components that you can edit in place, making changes easy to read and review, while keeping reproducibility of old migrations over time via pinning.

This is just the tip of the iceberg. View features below to see more of what Spawn enables.

Spawn in action: create a component, wire it into a migration, build the SQL, pin it, and apply it.

Terminal session showing spawn migration new, a colourised SQL build, spawn migration pin, and spawn migration apply, with the migration status table going from Pending to Applied

Powerful tests: loop over a JSON fixture, reuse a macro to seed each row, and run it against a fresh, ephemeral database. Use the result as your test's expected output.

SQL test file that creates a fresh ephemeral database, loops over a JSON fixture of customers, and calls a reusable create_customer macro to seed each one

Table of Contents

Installing

Install Spawn

Or simply:

# Install (macOS/Linux)
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/saward/spawn/releases/latest/download/spawn-db-installer.sh | sh

Quick Start

Initialize a new project, with an example docker compose setup ready to work out of the box via spawn init --docker (or just use spawn init for an existing project):

% spawn init --docker
Created docker-compose.yaml for database 'postgres'
Start the database with: docker compose up -d

▶ Spawn collects anonymous usage data.
  This helps us improve Spawn.
  Set "telemetry = false" in spawn.toml or use DO_NOT_TRACK=1 to opt-out.

Initialized spawn project with project_id: 5bb5a4eb-3677-4dc1-84d6-52b768180171
Created directories:
  spawn/migrations/
  spawn/components/
  spawn/tests/
  spawn/pinned/

Edit spawn.toml to configure your database connection.

This creates a docker-compose.yaml, a spawn.toml, and the spawn/ project structure:

% tree
.
├── docker-compose.yaml
├── spawn
│   ├── components
│   ├── migrations
│   ├── pinned
│   └── tests
└── spawn.toml

6 directories, 2 files

Start the database, and you're ready to create your first migration:

% docker compose up -d

Create and apply:

spawn migration new my-first-migration
# Edit the new migration up.sql, then apply:
spawn migration apply 20260903011209-my-first-migration --no-pin

Related docs:

Features

Familiar migrations

Migrations are just timestamped folders with an up.sql script:

.
├── docker-compose.yaml
├── spawn
│   ├── components
│   │   └── users
│   │       └── name.sql
│   ├── migrations
│   │   ├── 20260829121054-name-example
│   │   │   ├── lock.toml
│   │   │   └── up.sql
│   │   └── 20260829123838-update-name
│   │       └── up.sql

Apply a migration with spawn migration apply <migration>, or see status with spawn migration status:

% spawn migration apply 20260829121054-name-example
Migration '20260829121054-name-example' applied successfully
All migrations applied successfully.
% spawn migration status

┌─────────────────────────────┬────────────┬────────┬──────────┬───────────┐
│ Migration                   │ Filesystem │ Pinned │ Database │ Status    │
├─────────────────────────────┼────────────┼────────┼──────────┼───────────┤
│ 20260829121054-name-example │ ✓          │ ✓      │ ✓        │ ✓ Applied │
│ 20260829123838-update-name  │ ✓          │ ✗      │ ✗        │ ○ Pending │
└─────────────────────────────┴────────────┴────────┴──────────┴───────────┘

Related docs:

Reusable components

Spawn uses Minijinja under the hood to provide powerful templating abilities to your migrations.

Create reusable components and include them in a migration:

spawn/components/users/name.sql:

CREATE OR REPLACE FUNCTION get_name(first text, last text) RETURNS text AS $$
BEGIN
    RETURN first || ' ' || last; -- V1 Logic
END;
$$ LANGUAGE plpgsql;
% spawn migration new name-example

creating migration with name 20260829121054-name-example
creating migration at spawn/migrations/20260829121054-name-example/up.sql
New migration created: 20260829121054-name-example

spawn/migrations/20260829121054-name-example/up.sql:

BEGIN;
CREATE TABLE users (id serial, first text, last text);
{% include 'users/name.sql' %} -- Include the component
COMMIT;

Building the migration with spawn migration build 20260829121054-name-example produces:

BEGIN;
CREATE TABLE users (id serial, first text, last text);
CREATE OR REPLACE FUNCTION get_name(first text, last text) RETURNS text AS $$
BEGIN
    RETURN first || ' ' || last; -- V1 Logic
END;
$$ LANGUAGE plpgsql;
 -- Include the component
COMMIT;

Related docs:

Reproducible builds

Pin a migration (similar to git commit) via spawn migration pin <migration>, so that future changes to a component don't change the output of an old migration.

This allows you to edit a component in place, so that when you submit a PR, you can see exactly what's changed. Other tools often require you to duplicate some snippet, and then edit the copy, resulting in a big wall of green. Repeatable migrations help mitigate this, but have other limitations (e.g., running through migrations from start to finish).

Pin a migration:

% spawn migration pin 20260829121054-name-example
Migration pinned: 4219bf4255dee5b32b1154d68fa4fab2

Now if you edit spawn/components/users/name.sql and include it in a new migration, the old migration uses the old version of spawn/components/users/name.sql, ensuring that old migrations run as they once did.

This allows you to edit components in place, keeping the full git history of changes to them. No need to copy and then edit when making changes.

We can update our get_name function, changing the logic (spawn/components/users/name.sql):

...
    RETURN first || ' ' || substring(last, 1, 1); -- V2 Logic
...

Then create a new migration to apply the updated function to our database:

% spawn migration new update-name

creating migration with name 20260829123838-update-name
creating migration at spawn/migrations/20260829123838-update-name/up.sql
New migration created: 20260829123838-update-name

In that migration, import the component/function as we did in the first migration:

BEGIN;
-- Re-import the SAME component file, which now contains V2 logic
{% include 'users/name.sql' %}
COMMIT;

Now if we build the old migration, we see the old version of the name function still:

% spawn migration build 20260829121054-name-example --pinned
BEGIN;
CREATE TABLE users (id serial, first text, last text);
CREATE OR REPLACE FUNCTION get_name(first text, last text) RETURNS text AS $$
BEGIN
    RETURN first || ' ' || last; -- V1 Logic
END;
$$ LANGUAGE plpgsql;
 -- Include the component
COMMIT;

But the new migration shows the new logic:

% spawn migration pin 20260829123838-update-name
Migration pinned: 9a3a3d70587fca77197ade26877d589b
% spawn migration build 20260829123838-update-name --pinned
BEGIN;
-- Re-import the SAME component file, which now contains V2 logic
CREATE OR REPLACE FUNCTION get_name(first text, last text) RETURNS text AS $$
BEGIN
    RETURN first || ' ' || substring(last, 1, 1); -- V2 Logic
END;
$$ LANGUAGE plpgsql;

COMMIT;

The component changed, but the old migration still shows the same old logic while the new migration includes the new logic. This gives repeatable builds where you can rerun your migrations from start to finish, all while keeping a nice reviewable git history.

Related docs:

Golden file tests

Write tests to validate the behaviour of your functions, triggers, views, etc. Writing a test involves:

  1. Create the test, a single plain SQL file.
  2. Establish the expected output via an expect file (via spawn test expect <name>).
  3. Run test, which compares expected output to actual output.

Create a test, and apply the first migration from before:

% spawn test new get-name
creating test with name get-name
creating test at spawn/tests/get-name/test.sql
New test created: get-name
% spawn migration apply 20260829121054-name-example
Migration '20260829121054-name-example' applied successfully
All migrations applied successfully.
% spawn migration status

┌─────────────────────────────┬────────────┬────────┬──────────┬───────────┐
│ Migration                   │ Filesystem │ Pinned │ Database │ Status    │
├─────────────────────────────┼────────────┼────────┼──────────┼───────────┤
│ 20260829121054-name-example │ ✓          │ ✓      │ ✓        │ ✓ Applied │
│ 20260829123838-update-name  │ ✓          │ ✓      │ ✗        │ ○ Pending │
└─────────────────────────────┴────────────┴────────┴──────────┴───────────┘

Edit test.sql to call the function a few times:

SELECT get_name('John', 'Doe');
SELECT get_name('John', 'Duplicate');
SELECT get_name('Jane', 'Doe');
SELECT get_name('Jane', 'Duplicate');

We can see what the test will produce:

% spawn test run get-name
 get_name
----------
 John Doe
(1 row)

    get_name
----------------
 John Duplicate
(1 row)

 get_name
----------
 Jane Doe
(1 row)

    get_name
----------------
 Jane Duplicate
(1 row)

That looks right, so let's set this output as our expectation, and run to confirm it passes:

# This creates an expect file:
% spawn test expect get-name
% head -n 4 spawn/tests/get-name/expected
 get_name
----------
 John Doe
(1 row)
# This runs the actual test, comparing actual output to expected:
% spawn test compare get-name
[PASS] get-name

Now let's apply our next migration which changes how get_name works, and run the test again:

% spawn migration apply 20260829123838-update-name
Migration '20260829123838-update-name' applied successfully
All migrations applied successfully.
% spawn test compare get-name

Spawn in action

As expected, the test now fails because our get_name logic has changed. Here we see a colourful diff, highlighting the fact that our change in how get_name works has broken our test.

Related docs:

Reusable test functions

Spawn SQL tests are templates that can make use of the same powerful Minijinja templating features, along with some helper functions.

Reusable components

Components allow you to do powerful things. For example, perhaps you want to make it easy to create a particular record for tests. Let's say we have a database structure like so:

CREATE TABLE customer (
    customer_id BIGSERIAL PRIMARY KEY,
    name TEXT NOT NULL
);

CREATE TABLE address (
    address_id BIGSERIAL PRIMARY KEY,
    address_line TEXT NOT NULL
);

CREATE TABLE customer_address (
    customer_id BIGINT NOT NULL REFERENCES customer(customer_id),
    address_id BIGINT NOT NULL REFERENCES address(address_id),

    PRIMARY KEY (customer_id, address_id)
);

It's tedious to write the SQL to create test customers, so let's make a macro in spawn/components/tests/helpers/create_customer.sql:

{% macro create_customer(
  customer_id="DEFAULT" | safe,
  address_id="DEFAULT" | safe,
  name="Test Customer",
  address_line="1 Test Street",
) %}

insert into customer (
    customer_id,
    name
) values (
    {{ customer_id }},
    {{ name }}
)
returning customer_id as created_customer_id
\gset

insert into address (
    address_id,
    address_line
) values (
    {{ address_id }},
    {{ address_line }}
)
returning address_id as created_address_id
\gset

insert into customer_address (
    customer_id,
    address_id
) values (
    :created_customer_id,
    :created_address_id
);

{%- endmacro %}

This uses some psql features, allowing us to optionally provide address and customer id.

Then we can call it in a test (spawn/tests/create-customer/test.sql):

{% from "tests/helpers/create_customer.sql" import create_customer -%}

{{ create_customer() }}
{{ create_customer(address_id=4, name="Bob Jane") }}

SELECT * FROM customer;
SELECT * FROM address;
SELECT * FROM customer_address;

This makes it really simple to create test data for tests, overriding details when required. The SQL it produces is the same as the below, massively reducing repetition and making tests easier to read:

insert into customer (
    customer_id,
    name
) values (
    DEFAULT,
    'Test Customer'
)
returning customer_id as created_customer_id
\gset

insert into address (
    address_id,
    address_line
) values (
    DEFAULT,
    '1 Test Street'
)
returning address_id as created_address_id
\gset

insert into customer_address (
    customer_id,
    address_id
) values (
    :created_customer_id,
    :created_address_id
);


insert into customer (
    customer_id,
    name
) values (
    DEFAULT,
    'Bob Jane'
)
returning customer_id as created_customer_id
\gset

insert into address (
    address_id,
    address_line
) values (
    4,
    '1 Test Street'
)
returning address_id as created_address_id
\gset

insert into customer_address (
    customer_id,
    address_id
) values (
    :created_customer_id,
    :created_address_id
);

SELECT * FROM customer;
SELECT * FROM address;
SELECT * FROM customer_address;

When we run it, we see:

% spawn test run create-customer
 customer_id |     name
-------------+---------------
           1 | Test Customer
           2 | Bob Jane
(2 rows)

 address_id | address_line
------------+---------------
          1 | 1 Test Street
          4 | 1 Test Street
(2 rows)

 customer_id | address_id
-------------+------------
           1 |          1
           2 |          4
(2 rows)

Related docs:

Helper functions and utilities

Spawn provides a handful of helper functions and utilities that can be used in both migrations and tests.

Create a v4 uuid (most of the time you'd likely use the built in database uuid generation function):

INSERT INTO users (id, name) VALUES ({{ gen_uuid_v4() }}, {{ user_name }});

Include bytes from a file, which can be useful for testing:

INSERT INTO images (data) VALUES (decode({{ "images/logo.png"|read_file|base64_encode }}, 'base64'));

Run code only when applying to a dev database target:

{% if env == "dev" %}
-- Insert test data only in dev
INSERT INTO users (email) VALUES ('test@example.com');
{% endif %}

Use data passed in via --variables (e.g., variables.json):

{
  "table_name": "users",
  "admin_email": "admin@example.com"
}

And then reference it within your migration or test:

CREATE TABLE {{ variables.table_name | escape_identifier }} (
  id SERIAL PRIMARY KEY,
  email TEXT NOT NULL
);

INSERT INTO {{ variables.table_name | escape_identifier }} (email)
VALUES ({{ variables.admin_email }});

Related docs:

Secure by default

Spawn helps to protect your SQL from malicious input by making the secure option the default one. Spawn auto-escapes every {{ }} value as a SQL literal by default. E.g., consider the following insert:

INSERT INTO users (name, age) VALUES ({{ user_name }}, {{ user_age }});

If user_name is O'Reilly and user_age is 42, that produces:

INSERT INTO users (name, age) VALUES ('O''Reilly', 42);

And a malicious value is escaped automatically:

-- user_name = "'; DROP TABLE users; --"
INSERT INTO users (name) VALUES ('''; DROP TABLE users; --');

Sometimes, you need to use a value as an identifier (such as a table name) rather than a value. In those situations, you can use the escape_identifier filter:

SELECT * FROM my_schema.{{ table_name | escape_identifier }} my_table;

Or if you know the value is safe and you want to use it as it is, unmodified and unescaped, you can do so with the safe filter:

{% set conditions = "status = 'active' AND created_at > NOW() - INTERVAL '1 day'" %}
SELECT * FROM users WHERE {{ conditions | safe }};

Related docs:

Data from JSON

In the preceding section, we saw a way to pass in variables as a command line parameter. But sometimes you may want to make use of data from a fixture that you can use in tests automatically.

Let's say we want to create a handful of customers, using the macro from the Reusable components section above, but making use of data in a file in our components folder. spawn/components/tests/helpers/customers.json:

[
  {
    "name": "Alice Brown",
    "address": "1 King Street"
  },
  {
    "name": "Ben Carter",
    "address": "22 High Street"
  },
  {
    "name": "Chloe O'Davis",
    "address": "7 Station Road"
  },
  {
    "name": "Daniel Evans",
    "address": "14 Market Lane"
  },
  {
    "name": "Emma Foster",
    "address": "3 Victoria Avenue"
  }
]

We might want to loop over this to create a handful of test cases. To do that, in our test (this works for migration scripts too), you can import this JSON and loop over it, using our macro to create test clients:

{% from "tests/helpers/create_customer.sql" import create_customer -%}

-- Use 'WITH TEMPLATE' so you can run the test repeatedly with a fresh
-- copy each time:
DROP DATABASE IF EXISTS create_customer_test;
CREATE DATABASE create_customer_test WITH TEMPLATE postgres;
\c create_customer_test

{% set customers = "tests/helpers/customers.json" | read_json %}
{% for customer in customers %}
  {{ create_customer(name=customer.name, address_line=customer.address) }}
{% endfor %}

SELECT * FROM customer;
SELECT * FROM address;
SELECT * FROM customer_address;

\c postgres
DROP DATABASE IF EXISTS create_customer_test;

And then if we run it, we see the test created all our customers from the JSON fixture:

% spawn test run create-customer
 customer_id |     name
-------------+---------------
           3 | Alice Brown
           4 | Ben Carter
           5 | Chloe O'Davis
           6 | Daniel Evans
           7 | Emma Foster
(5 rows)

 address_id |   address_line
------------+-------------------
          2 | 1 King Street
          3 | 22 High Street
          4 | 7 Station Road
          5 | 14 Market Lane
          6 | 3 Victoria Avenue
(5 rows)

 customer_id | address_id
-------------+------------
           3 |          2
           4 |          3
           5 |          4
           6 |          5
           7 |          6
(5 rows)

Related docs:

GitHub action

Spawn has a GitHub action you can include to run your tests and check for any unpinned migrations (it's usually best to pin a migration once you're finished with it):

- name: Install Spawn
  uses: saward/spawn-action@v1

- name: Run check
  run: |
    spawn check

- name: Run tests
  run: |
    spawn test compare test-1
    spawn test compare test-2

Related docs:

Multiple database targets

You can specify multiple database targets in your spawn.toml config file. For example, you can set up a postgres-psql target:

# spawn.toml
[targets.local]
engine = "postgres-psql"
spawn_database = "postgres"
spawn_schema = "_spawn"
environment = "dev"

[targets.local.command]
kind = "direct"
direct = ["docker", "exec", "-i", "postgres-db", "psql", "-U", "postgres", "postgres"]

And then execute commands against a specific target. E.g.:

% spawn migration status --target local

┌─────────────────────────────┬────────────┬────────┬──────────┬───────────┐
│ Migration                   │ Filesystem │ Pinned │ Database │ Status    │
├─────────────────────────────┼────────────┼────────┼──────────┼───────────┤
│ 20260829121054-name-example │ ✓          │ ✓      │ ✓        │ ✓ Applied │
│ 20260829123838-update-name  │ ✓          │ ✓      │ ✓        │ ✓ Applied │
└─────────────────────────────┴────────────┴────────┴──────────┴───────────┘

Connecting to production databases can be configured to use all your standard commands. You just need to provide it with a valid psql pipe. Spawn supports Provider Commands. Configure it to use gcloud, aws, or az CLIs to resolve the connection or SSH tunnel automatically.

# spawn.toml
[targets.prod]
...

[targets.prod.command]
kind = "provider"
provider = ["gcloud", "compute", "ssh", "--dry-run", ...]
append = ["psql", ...]
...

Related docs:

Managing secrets

If you want to include secrets in a migration, Spawn provides ways to include them that help to protect them from accidentally leaking in logs, or being committed to git history. Include a secret in your migration like this:

CREATE ROLE app_user WITH LOGIN PASSWORD {{ secret("application_password") }};

And update your spawn.toml to let Spawn know how to find the secret:

[secrets.application_password.default]
source = "host_file"
path = "/run/secrets/application-password"

[secrets.application_password.environments.dev]
source = "literal"
value = "some-local-dev's-pass"
insecure = true

The default password source will be used for all environments other than dev. For dev database targets, we have an override specified so that it will use a hard-coded literal.

When you build the migration, the secret will be fetched by Spawn, but will be masked in the output:

% spawn migration build 20260909055412-secrets-example
BEGIN;

CREATE ROLE app_user WITH LOGIN PASSWORD '***MASKED:application_password***';

COMMIT;

If you want to see the real password in the build output, use --reveal-secrets:

% spawn migration build 20260909055412-secrets-example --reveal-secrets
BEGIN;

CREATE ROLE app_user WITH LOGIN PASSWORD 'some-local-dev''s-pass';

COMMIT;

When applying a migration, the secret will be used unmasked. Tests always mask secrets.

Related docs:

Comparison

Feature Spawn Sqitch Flyway dbmate
Core Philosophy Compiled. Database logic is a codebase. Migrations are build artifacts. DAG. A dependency graph of changes. No linear version numbers. Linear. Run scripts V1 → V2. "Repeatable" scripts run at the end. Simple. Just run these SQL files in order.
Views/Functions Pinned Components. Edit in place. Snapshots locked per-migration (CAS). Versioned Copies. The rework command creates a new physical file for old migrations. Repeatable. Re-runs R__ scripts every migration. Doesn't track history. Manual. Copy-paste old logic into new migrations manually.
Templating Native (Minijinja). Macros, loops, and variables inside SQL. None. Raw SQL only. Basic. ${placeholder} substitution only. None. Raw SQL only.
Testing Built-in. spawn test with diff-based assertions against a real database. Verify Scripts. Boolean (Pass/Fail) scripts run after deploy. None. Relies on external CI tools. None.
Dependencies Single Binary (Rust) + psql CLI. Perl. JRE / Binary. Single Binary (Go). Very easy install.
Rollbacks 🔄 Planned. Currently manual. First Class. Every change must have a revert script. Paid. Undo functionality often gated behind Pro/Enterprise. Supported. down.sql files are standard.
DB Support PostgreSQL (Focus on depth). Massive. Postgres, MySQL, Oracle, SQLite, Vertica, etc. Massive. Every DB known to man. Broad. Postgres, MySQL, SQLite, ClickHouse.
Execution Engine Native CLI Wrapper. Full parity with psql (supports \copy, \gset, \set). Native Drivers. JDBC. (Java Database Connectivity). Native Drivers. (Go drivers).
License AGPL-3.0 MIT Apache 2.0 (Community) / Proprietary (Teams). MIT

Roadmap

Spawn is currently in Public Beta. It is fully functional and has test suites to help prevent regressions, but should be considered experimental software. We recommend testing thoroughly before adopting it for critical production workloads.

Currently Supported:

  • ✅ PostgreSQL via psql support
  • ✅ Core Migration Management (Init, New, Apply)
  • ✅ Component Pinning & CAS
  • ✅ Minijinja Templating
  • ✅ Testing Framework (Run, Expect, Compare)
  • ✅ Database Tracking & Advisory Locks
  • CI/CD Integration

What's Next:

  • 🔄 Rollback Support: Optional down scripts for reversible migrations.
  • 🔄 Additional Engines: Native PostgreSQL driver, MySQL, and more.
  • 🔄 Multi-Tenancy: First-class support for schema-per-tenant migrations.
  • 🔄 External Data Sources: Better support for data from files, URLs, and scripts in templates.
  • 🔄 Plugin System: Custom extensions for engines, data sources, and workflows.

(See Roadmap for detailed tracking)


Documentation

Full documentation, recipes, and configuration guides are available at:

Telemetry

Spawn collects anonymous usage data, to help us improve Spawn. Set "telemetry = false" in spawn.toml or use DO_NOT_TRACK=1 to opt-out.

Contributing

Please read CONTRIBUTING.md before opening a PR. Note that this project requires signing a CLA.

LLM Disclaimer

I (Mark) estimate that 90% of the code/design/architecture has been done by myself (2026-02-12), but I do use LLM's for filling in tedious gaps. All LLM changes are reviewed to ensure they fit with the current design and future vision.