Schema Migration Tool — Google Cloud Spanner

spanchange Versioned migrations for every Spanner paradigm

The only migration tool built for all six Spanner paradigms. Manage relational tables, full-text search, vector/AI embeddings, property graphs, and analytics TTL policies — all from versioned SQL scripts. Enterprise tier adds policy-as-code contracts, live schema analysis, database diff, capacity projections, and CI/CD dashboards. 19 CLI commands. No JVM.

spanchange — production deploy
$ spanchange contract check --profile prod --github-annotations
⚠ [S003] users.email — STRING(MAX) on indexed column (prefer STRING(N))
→ Change to STRING(255) to reduce index storage cost
$ spanchange analyse --profile prod
✓ users 12 cols 95/100
⚠ products 18 cols 71/100 [A009] 3+ indexes
$ spanchange schema-diff --source staging --target prod
✖ [BREAKING] products.embedding — FLOAT32(512) vs FLOAT32(768)
$ spanchange deploy --profile prod --tag 2.0.0
[lock] acquired (ci-runner-prod)
[RUN] V2.0.0__add_product_search.sql 4.2s
[RUN] V2.0.1__add_embeddings.sql 1.8s
[SKIP] V1.5.0__create_graph.sql already applied
✓ myapp-prod: run 2 | skip 1 | errors 0
$
19
CLI Commands
6
Spanner Paradigms
24
Linter Rules
DAG
Multi-DB Pipeline

🗄️
Relational
Tables, foreign keys, interleaved parent-child tables, secondary indexes. Full lifecycle with interleave-aware rollback.
🔑
Key / Value
Simple primary-key tables managed identically to relational. Linter enforces correct Spanner types (INT64, not INTEGER).
🔍
Full-text Search
TOKENLIST columns, SEARCH INDEX lifecycle. Linter catches the HIDDEN attribute requirement and SEARCH() usage patterns.
🧠
Vector / AI
ARRAY<FLOAT32> embedding columns, VECTOR INDEX with COSINE / DOT_PRODUCT / EUCLIDEAN metrics. Full drift detection.
🕸️
Graph
CREATE PROPERTY GRAPH with NODE TABLES and EDGE TABLES. Linter validates source/destination key references.
📊
Analytics
ROW DELETION POLICY (TTL). Advisor warns when adding a policy to a table that already has expired rows.
One tool. One history table. Six paradigms. Every change versioned, checksummed, and rollback-ready.

Protocol

How It Works

Every deploy follows a deterministic 9-step sequence. Nothing executes without validation. Nothing executes twice.

01

Assert Database Exists

Verifies the target Spanner database is reachable. Exits immediately with a clear error if the database or instance is unavailable.

02

Ensure History Tables

Creates spanchange_history, spanchange_lock, and spanchange_audit if they don't exist. No manual setup required.

03

Acquire Deploy Lock

Uses a Spanner READ_WRITE transaction to atomically claim the lock row. Concurrent deploys are blocked at the database level.

04

Discover Scripts

Recursively walks the migrations folder, classifies by V/U/R/A prefix, and sorts globally by semver — regardless of subdirectory layout.

05

Apply Tag Fence

If --tag is set, only scripts whose tag is ≤ the fence version are applied. Scripts with no tag are always included.

06

Execute DDL Statements

Each script's DDL is submitted via UpdateDatabaseDdl. spanchange polls until the long-running operation completes — handling Spanner's async DDL correctly.

07

Run R__ & A__ Scripts

Repeatable scripts rerun when checksum changes. Always scripts run unconditionally — designed for IAM grants and permission refreshes.

08

Record History

A row is written for every script: status, checksum, execution time, tag, run_id, and operator identity. The audit log captures every event append-only.

09

Release Lock

The lock is released via the same Spanner transaction used to acquire it. Only the process that acquired it can release — no orphaned locks possible.

spanchange never creates databases or instances. The target Spanner database must be provisioned externally via Terraform or gcloud. Database creation is an infrastructure concern — schema migration is spanchange's concern.

Conventions

Four Script Types

The filename is the contract. No XML changelogs, no YAML config — just well-named .sql files in any folder structure you choose.

V

Versioned

Runs once in ascending semver order. Never re-runs. The workhorse of schema evolution — CREATE TABLE, ALTER TABLE, search/vector/graph DDL.

V2.0.0__add_product_search.sql
U

Undo

Paired rollback for a versioned script. Only executes on spanchange rollback. Rollback is interleave-aware — child tables always dropped before parents automatically.

U2.0.0__add_product_search.sql
R

Repeatable

Reruns on deploy when its MD5 checksum changes. Ideal for views, UDFs, and lookup data that must stay in sync with the codebase.

R__product_search_settings.sql
A

Always

Executes on every deploy — no conditions. Use for IAM grants and permission refreshes that must always be current.

A__refresh_iam_grants.sql

Scripts are discovered recursively. Organise by paradigm, team, or release. Version ordering is always global across all subdirectories.

migrations/ — by paradigm layout
relational/ V1.0.0__create_customers.sql V1.1.0__add_orders.sql U1.1.0__add_orders.sql search/ V2.0.0__add_product_search.sql U2.0.0__add_product_search.sql vectors/ V3.0.0__add_embeddings.sql U3.0.0__add_embeddings.sql graph/ V4.0.0__create_product_graph.sql analytics/ V5.0.0__add_ttl_policies.sql R__search_settings.sql A__refresh_iam_grants.sql

CLI

Twelve Commands, One Tool

Deploy, rollback, validate, lint, drift detect, advise, shadow test, pipeline orchestrate, diff, baseline, generate, repair — everything in one binary.

deploy
locked
Apply all pending migrations in semver order. Respects tag fences. Acquires Spanner transaction lock before running any script.
spanchange deploy --profile prod spanchange deploy --profile prod --tag 2.0.0 spanchange deploy --dry-run
rollback
interleave-aware
Roll back using paired U__ undo scripts. Reads INFORMATION_SCHEMA to resolve safe drop order for interleaved parent-child tables automatically.
spanchange rollback --profile prod spanchange rollback --target-version 1.1.0 spanchange rollback --tag 2.0.0 --dry-run
lint
offline
Run all 24 DDL linter rules against your scripts. No database connection required. Catches Spanner incompatibilities before deploy.
spanchange lint spanchange lint --fail-on-warn spanchange lint --skip-rules L010 L011
drift
6 paradigms
Compare migration history against the live database across all six paradigms. Detects out-of-band manual DDL changes and partial failures.
spanchange drift --profile prod # TABLE_MISSING, SEARCH_INDEX_EXTRA, # VECTOR_INDEX_MISSING, PROPERTY_GRAPH_EXTRA
advise
offline
Zero-downtime advisor. Analyses scripts for operations that may cause latency spikes, data loss, or unavailability during live traffic.
spanchange advise --profile prod spanchange advise --skip-rules A001 # warns: DROP TABLE, type changes, 3+ indexes
shadow
end-to-end
Replay full migration history against a fresh Spanner emulator. Lint + deploy + multi-paradigm drift in sequence — before touching production.
spanchange shadow --emulator docker spanchange shadow --emulator gcloud spanchange shadow --no-lint --no-drift
pipeline
DAG
Deploy to multiple Spanner databases in dependency order. DAG-based stage ordering with version constraints. Downstream stages blocked on failure.
spanchange pipeline --profile prod spanchange pipeline --dry-run spanchange pipeline --continue-on-failure
validate
offline
Validate script naming, version ordering, duplicate versions, and orphaned undo scripts. No database connection required.
spanchange validate spanchange validate --root-folder ./migrations # checks: naming, duplicates, orphan undos
diff
read-only
Show pending migrations without executing anything. Cleaner than --dry-run for human review. Use --exit-code as a CI gate.
spanchange diff --profile prod spanchange diff --profile prod --tag 2.0.0
baseline
introspect
Introspect the live database and generate a complete V0.0.0 baseline SQL file covering all six paradigms. Perfect for adopting spanchange on an existing database.
spanchange baseline --profile prod spanchange baseline --version 1.0.0 # captures: tables, indexes, search, vectors, graphs, TTL
generate
scaffold
Scaffold a new versioned migration and undo script pair with the correct version number, filename, and template content.
spanchange generate --version 2.0.0 \ --description "add product search" # V2.0.0__add_product_search.sql # U2.0.0__add_product_search.sql
repair
recovery
Recover from failed deployments or stuck locks. Force-release a lock held by a dead process. Mark FAILED scripts as REPAIRED for retry.
spanchange repair --list spanchange repair --release-lock spanchange repair --script V2.0.0__foo.sql

Static Analysis

24 Linter Rules

Offline DDL analysis catches MySQL/Postgres idioms Spanner rejects, Spanner-specific correctness issues, and paradigm-specific hazards — entirely without a database connection.

Compatibility — MySQL/Postgres idioms Spanner rejects
RuleSevWhat it catches
L002ERRORBEGIN TRANSACTION wrapping DDL — not transactional in Spanner
L003ERRORAUTO_INCREMENT / SERIAL / IDENTITY — not supported
L007ERRORINT / INTEGER type — use INT64
L008ERRORFLOAT / DOUBLE / REAL type — use FLOAT64
L013ERRORRENAME COLUMN — not supported in GoogleSQL
Correctness — DDL Spanner handles badly or rejects
RuleSevWhat it catches
L001ERRORNOT NULL column via ALTER TABLE with no DEFAULT value
L004WARNSTRING column with no length — use STRING(N) or STRING(MAX)
L005WARNBYTES column with no length
L006ERRORDROP COLUMN with dependent index in same script
L009ERRORCREATE TABLE with no PRIMARY KEY clause
L010INFOTIMESTAMP column without allow_commit_timestamp=true
L011WARNINTERLEAVE IN PARENT references unknown table
L012ERRORDROP TABLE without dropping dependent indexes first
Full-text Search (TOKENLIST & SEARCH INDEX)
RuleSevWhat it catches
L014ERRORTOKENLIST column without HIDDEN attribute — always required
L015WARNCREATE SEARCH INDEX with no TOKENLIST column in scope
L016ERRORLIKE on a TOKENLIST column — use SEARCH() instead
Vector / AI (FLOAT32 ARRAY & VECTOR INDEX)
RuleSevWhat it catches
L017ERRORFLOAT32 ARRAY column with no VECTOR LENGTH declared
L018WARNCREATE VECTOR INDEX with no distance_type in OPTIONS
L019ERRORARRAY<FLOAT64> used — vector indexes require FLOAT32
Graph (PROPERTY GRAPH) & Analytics (ROW DELETION POLICY)
RuleSevWhat it catches
L020WARNNODE TABLE references table not found in migrations
L021ERROREDGE TABLE without SOURCE KEY or DESTINATION KEY
L022ERRORCREATE PROPERTY GRAPH with no NODE TABLES clause
L023ERRORROW DELETION POLICY on a non-TIMESTAMP column
L024WARNROW DELETION POLICY references column not in this script

Drift & Advice

Catch What Changed Without You

Drift detection compares migration history against the live database across all six paradigms. The zero-downtime advisor tells you what your pending scripts may do to production.

Six-paradigm drift detection

Every paradigm has its own drift detection path. Relational tables check INFORMATION_SCHEMA.TABLES and COLUMNS. Search indexes check SEARCH_INDEXES. Vector indexes check VECTOR_INDEXES. Property graphs check PROPERTY_GRAPHS.

All compared against what migration history says should exist. ERROR items mean a resource declared in migrations is absent from the live database — your application may already be broken.

TABLE_MISSING
Table in history but absent from live database
TABLE_EXTRA
Table in live database, never in any migration
SEARCH_INDEX_MISSING
Search index declared, absent from database
VECTOR_INDEX_EXTRA
Vector index exists but not in any migration
PROPERTY_GRAPH_EXTRA
Property graph created outside migrations
ROW_DELETION_POLICY_MISSING
TTL policy declared, removed from database

Zero-downtime advisor

Where drift tells you what changed without your knowledge, the advisor tells you what your pending scripts might do to a live system. Runs offline — no database connection needed. Never blocks a deploy; always informs.

RuleLevelRisk
A001INFOIndex creation — async backfill in progress
A002WARNDROP COLUMN — app may still reference it
A003WARNDROP TABLE — data permanently deleted
A005WARNColumn type change — may break existing readers
A009WARN3+ indexes in one script — concurrent backfill load
A011WARNSEARCH INDEX — heavier backfill than regular index
A014WARNFLOAT32 ARRAY alter — all vector indexes must be rebuilt
A017WARNROW DELETION POLICY on table with already-expired data

Shadow Testing

Verify the Full Chain Before Production

spanchange shadow replays every migration against a fresh Spanner emulator — lint, deploy, drift check — and tears it down without touching production.

01

Start Emulator

Launches the Spanner emulator via Docker, gcloud, or connects to an existing instance. Automatically creates a throw-away database.

02

Run All 24 Lint Rules

Every linter rule executes against the full script set. Any ERROR-level finding stops the shadow run before touching the emulator.

03

Replay Full Migration History

Every V__ script is applied in order from the beginning of history — not just pending scripts. The complete chain must succeed cleanly.

04

Multi-Paradigm Drift Check

After replay, drift detection runs across all six paradigms against the emulator. History says X exists — does it?

05

Tear Down

The throw-away database and emulator are destroyed. No trace remains. Production is untouched throughout the entire process.

GitHub Actions — shadow test step
- name: Shadow test (full chain) run: | spanchange shadow \ --profile prod \ --emulator docker \ --tag ${{ github.ref_name }} # Sequence on every push to main: # 1. docker pull spanner-emulator # 2. lint all scripts (24 rules) # 3. replay full migration history # 4. drift check across 6 paradigms # 5. tear down emulator # → no production instance touched

Orchestration

DAG-Based Multi-DB Pipeline

Deploy to multiple Spanner databases in dependency order. Define a directed acyclic graph of stages — spanchange resolves the order, checks version constraints, and marks downstream stages blocked on failure.

spanchange.yml — pipeline configuration
pipeline: - name: config-db profile: prod root_folder: ./migrations/config must_be_at: 2.0.0 # version constraint check - name: core-db profile: prod root_folder: ./migrations/core depends_on: config-db - name: reporting-db profile: prod root_folder: ./migrations/reporting depends_on: - core-db - config-db skip_advise: true

Pipeline output

After all stages complete, spanchange prints a structured summary. Blocked stages clearly reference the failing dependency. Use --continue-on-failure to proceed through blocked stages for partial deployments.

Pipeline Summary
════════════════════════════════════════════
✓ config-db SUCCESS (0 run, 3 skip, 12ms)
✓ core-db SUCCESS (4 run, 1 skip, 892ms)
✗ reporting-db FAILED → DDL failed on V2.1.0
⊘ analytics-db BLOCKED (dep: reporting-db)
════════════════════════════════════════════
Pipeline FAILED (4.2s)

Coverage

Full Paradigm Matrix

All six Spanner paradigms are first-class. Each has dedicated linter rules, advisor rules, drift detection, and baseline capture.

ParadigmDDL ManagedLinterAdvisorDriftBaseline
🗄️ RelationalTables, columns, indexesL001–L013A001–A010Tables, columns, indexes
🔑 Key / ValueSimple primary-key tablesL001–L013A001–A010Tables, columns
🔍 Full-text SearchTOKENLIST + SEARCH INDEXL014–L016A011–A012SEARCH_INDEXES
🧠 Vector / AIFLOAT32 ARRAY + VECTOR INDEXL017–L019A013–A014VECTOR_INDEXES
🕸️ GraphPROPERTY GRAPH definitionL020–L022A015–A016PROPERTY_GRAPHS
📊 AnalyticsROW DELETION POLICY (TTL)L023–L024A017TABLE_OPTIONS

CLI — Enterprise

Seven Enterprise Commands

Policy-as-code governance, live schema health scoring, database comparison, capacity projections, monitoring snapshots, synthetic data seeding, and CI/CD analytics dashboards.

contract
ENTERPRISE
Policy-as-code schema governance. Enforces naming conventions, NULL constraints, index limits, STRING(MAX) restrictions, and custom rules. GitHub PR annotations. Built-in rules S001–S015 plus custom rules. Exits 2 on failures.
spanchange contract init # generate starter file spanchange contract check --profile prod spanchange contract check --github-annotations
analyse
ENTERPRISE
Live schema health scorer 0–100 per table. Checks hotspot risk, index hygiene, TTL coverage, vector index coverage, and interleave hierarchy depth. Text, JSON, and Markdown output.
spanchange analyse --profile prod spanchange analyse --format json spanchange analyse --format markdown --output report.md
schema-diff
ENTERPRISE
Compare two live Spanner databases schema-to-schema. Diffs tables, columns, indexes, search indexes, vector indexes, property graphs, and TTL policies. BREAKING/WARNING/INFO severity. Exit 2 on breaking differences — hard CI gate.
spanchange schema-diff --source staging --target prod spanchange schema-diff --format markdown --output diff.md # exit 0=identical 1=differences 2=BREAKING
monitor
ENTERPRISE
Snapshot live database health: row counts, storage, and index counts from Cloud Monitoring. Flags large tables, unbounded growth, and missing TTL policies. Writes to spanchange_monitor_snapshots for capacity projections.
spanchange monitor --profile prod spanchange monitor --table products --json spanchange monitor --no-snapshot # read-only
capacity
ENTERPRISE
Project data growth using OLS linear regression over monitor snapshot history. Shows days until row count and storage thresholds are crossed with R² nonlinearity detection. Requires three or more prior monitor runs.
spanchange capacity --profile prod spanchange capacity --horizon 180 # 180-day window spanchange capacity --table users --verbose
seed
ENTERPRISE
Schema-aware synthetic data seeder. Introspects INFORMATION_SCHEMA for column types and semantic hints (email, phone, status, lat/lon). Respects interleave hierarchies — parent tables seeded before children. STRING(N) limits enforced.
spanchange seed --profile dev --rows 10000 spanchange seed --table orders --seed 42 spanchange seed --dry-run # preview without writing
dashboard
ENTERPRISE
Generate a self-contained HTML CI/CD analytics dashboard from migration history. Deploy success rate, p95 deploy time, daily activity chart, slowest scripts, most-failed scripts, and release tag timeline. No server — open in a browser.
spanchange dashboard --profile prod spanchange dashboard --days 30 --output ./reports/dash.html # self-contained HTML, no server required

Configuration

Multi-Database Profiles

A single profile can target multiple Spanner databases with independent migration paths. Every command — deploy, rollback, status, drift, analyse, monitor — runs against each database in sequence automatically.

spanchange.yml — multi-database Form B
profiles: prod: project: gcp-learning-app instance: my-spanner-instance database: spandemo-app: root_folder: ./migrations/app history_table: spanchange_history spandemo-analytics: root_folder: ./migrations/analytics history_table: spanchange_history timeout: 600 # Corresponding folder layout: # migrations/ # app/ ← V/U scripts for spandemo-app # analytics/ ← V/U scripts for spandemo-analytics
All commands iterate automatically
$ spanchange deploy --profile prod ━━ DATABASE gcp-learning-app/instance/spandemo-app ━━ [RUN] V1.2.0__add_search_index.sql 4.1s ━━ DATABASE gcp-learning-app/instance/spandemo-analytics ━━ [RUN] V1.0.0__analytics_tables.sql 2.3s # status, drift, monitor — all iterate automatically spanchange status --profile prod spanchange drift --profile prod spanchange monitor --profile prod

Two forms — fully backward-compatible

Form A (original): database: spandemo — single database, unchanged behaviour.

Form B (new): database as a mapping — each key is a database name with its own root_folder, history_table, and timeout override. Each database has an independent history table and tracks applied versions separately.

You can be at V1.3.0 on the app database and V1.0.0 on the analytics database simultaneously. The pipeline respects multi-database profiles — each stage deploys all databases in that profile.

Works with pipeline: Multi-database profiles work with spanchange pipeline — each stage deploys all databases in the profile. Combine with per-stage tag fences and pause_before gates for a complete multi-tenant release workflow.

CI / CD

Five-Stage Production Pipeline

From PR check to production deploy — a complete GitHub Actions workflow with Workload Identity Federation, contract checks, shadow testing, and post-deploy verification.

Stage 01

validate + lint

Offline checks on every PR. Naming, version ordering, 24 DDL rules. Zero network calls — fast, always safe.

auto on PR
Stage 02

contract check

Policy-as-code gate against the live database. Naming conventions, NULL constraints, index limits. GitHub PR annotations. Exits 2 on failures — blocks merge.

auto on PR
Stage 03

shadow + schema-diff

Full emulator replay + drift check. Compare staging schema against prod before promoting — exits 2 on BREAKING differences.

auto on main
Stage 04

deploy + drift

Deploy with Workload Identity. Post-deploy drift check confirms every resource from applied scripts exists in the live database.

auto on main
Stage 05

monitor + dashboard

Snapshot database health metrics after every deploy. Update the CI/CD analytics dashboard for team visibility.

environment gate

Comparison

How spanchange Compares

The only tool built for Spanner's multi-paradigm nature. 19 commands spanning all six paradigms, policy-as-code contracts, live schema analysis, capacity projections, and multi-database pipelines — no JVM, no external database.

CapabilityspanchangeGeneric Tool AGeneric Tool BTerraform
Spanner GoogleSQL native✓ Native⚠ Generic SQL⚠ Generic SQL
SEARCH INDEX lifecycle
VECTOR INDEX lifecycle
PROPERTY GRAPH lifecycle
ROW DELETION POLICY (TTL)
Interleave-aware rollback✓ auto
24-rule DDL linter
Zero-downtime advisor✓ 17 rules
Shadow / emulator testing
Multi-DB DAG pipeline⚠ manual⚠ modules
Multi-database profiles✓ Form B⚠ workspaces
Deploy history + audit log
Distributed deploy lock✓ Spanner TX⚠ DB-dependent⚠ DB-dependent
Policy-as-code contracts✓ Enterprise
Live schema health scoring✓ 0–100/table
Schema diff (DB compare)✓ BREAKING/WARN✓ plan
Capacity projections (OLS)✓ Enterprise
Interleave-aware seeder✓ Enterprise
CI/CD analytics dashboard✓ HTML
No JVM required✓ PythonJVM requiredJVM required✓ HCL

Installation

Get Started in Minutes

Python 3.8+. No JVM, no containers required. Authentication via Application Default Credentials or a service account key.

📦

Install

pip or from package

pip install spanchange
⚙️

Configure

spanchange.yml

project: my-gcp-project root_folder: ./migrations profiles: prod: # Form A — single db instance: my-instance database: my-database prod-multi: # Form B — multi-db instance: my-instance database: app-db: root_folder: ./migrations/app analytics-db: root_folder: ./migrations/analytics
✍️

First Migration

V1.0.0__create_users.sql

-- V1.0.0__create_users.sql CREATE TABLE users ( user_id STRING(36) NOT NULL, email STRING(255) NOT NULL, created_at TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true), ) PRIMARY KEY (user_id);
🚀

Deploy

validate → contract → deploy → monitor

spanchange validate && spanchange lint spanchange contract check --profile staging spanchange schema-diff --source staging --target prod spanchange shadow --emulator docker spanchange deploy --profile prod spanchange drift --profile prod spanchange monitor --profile prod spanchange dashboard --profile prod