Versioning SpiceDB Schemas: An Architecture Note on Safe Policy Migration
SpiceDB applies your authorization schema atomically, but relationship backfills are not. Here is a four-phase rollout that avoids silent permission regressions.
25 Jan 2026, 12:35 UTC

SpiceDB applies your authorization schema as a single whole. There is no per-definition migration script and no built-in history table for your policy. Teams that assume otherwise — treating a policy change like an incremental SQL migration — ship silent permission regressions, because the schema write succeeds while the data underneath it does not yet match.
The practical takeaway: split every policy change into an atomic schema write plus a non-atomic relationship backfill, and gate the cutover on negative permission checks rather than on the schema write returning success.
Two different things are called "migration"
SpiceDB's own datastore tables — the internal storage for relationships and revisions — are managed by the SpiceDB binary. Upgrading the binary may require running its datastore migration path before the new server will start.
Your authorization schema is a separate artifact. It defines object types, relations and permissions, and it is written through the API or the zed CLI. A write replaces the previous schema in one operation.
These two paths have different owners, different failure modes and different rollback stories. Conflating them is the most common source of confusion in upgrade planning. Exact subcommands and flags for the datastore migration path vary by release — confirm them against the release notes for the version you actually run before scripting anything.
Requirements worth writing down first
- No window where a legitimate user is denied access they had before.
- No window where a user gains access they should not have.
- Every schema change is reviewable in a diff and revertible with one write.
- Runtime application services never hold schema-write credentials.
The smallest design that satisfies them
Three pieces, no new infrastructure:
- Schema files in git. The deployed artifact is the file content, not a hand-typed command. Keep the previous file so revert is a single write.
- A four-phase rollout: additive schema write, then backfill, then application cutover, then cleanup.
- A verification script that runs positive and negative permission checks against the target instance.
Worked example: adding a billing administrator
Start with a schema where billing administration is gated on ordinary membership:
definition organization {
relation member: user
permission view = member
permission administer_billing = member
}
Phase 1 adds a new relation and a new permission, and removes nothing:
definition organization {
relation member: user
relation billing_admin: user
permission view = member + billing_admin
permission administer_billing = billing_admin
}
After phase 1, nobody new can administer billing, because no billing_admin relationships exist yet. Phase 2 backfills them. Phase 3 switches application code to check administer_billing. Only in phase 4, after the backfill is verified, do you remove the old grant path.
The ordering rule that matters: never narrow a permission in the same write that introduces its replacement. If you had written administer_billing = billing_admin before the backfill ran, every existing administrator would have lost access the moment the schema landed.
Illustrative commands
Run these from an operator workstation with the zed CLI pointed at the target SpiceDB endpoint. Placeholders: organization:acme and user:ana. The schema write needs a token with schema-write permission; the backfill needs relationship-write; the check needs only read. Confirm flag names against zed --help for your version — these are illustrative, not tested here.
# capture the running schema so you can diff and revert
zed schema read > current.zed
# apply the additive schema
zed schema write schema.zed
# backfill one relationship (or batch via bulk import)
zed relationship create organization:acme billing_admin user:ana
# verify a single decision
zed permission check organization:acme administer_billing user:ana
Trust and data boundaries
- Schema-write is a global authorization primitive. A token that can write schema can grant any user any permission. Keep it in a deployment pipeline or a short-lived operator credential.
- Runtime services get scoped credentials — check-only, plus relationship writes limited to the object types they own.
- Do not edit datastore tables directly. SpiceDB's validation runs on the API path. SQL writes skip it and can leave relationships that no schema accepts.
- The git file is the source of truth; the running schema is a copy. Drift between them is the failure you are actually guarding against.
Operational checks
Before: apply the schema to a staging instance holding a copy of production relationships — a restored datastore snapshot, not an empty one. The schema write itself is the test: SpiceDB validates a new schema against existing relationships and rejects one they do not conform to. Verify that behavior for your version, because the whole design leans on it.
After: re-read the schema and diff it against the file you wrote, then run both check directions.
| Check | What it catches | How |
|---|---|---|
| Schema diff | Wrong file deployed, partial apply | zed schema read diffed against git |
| Positive checks | Broken grant path, missing backfill | Check a user who should have access |
| Negative checks | Over-permissioning | Check a user who should not have access |
| Backfill count | Incomplete or duplicated backfill | Compare expected vs. actual relationship count |
Use a fully-consistent read for verification so you are not reading a stale snapshot from a replica.
Failure modes
- Schema write rejected. The operation is atomic, so nothing changed. This is the safe failure; fix and retry.
- Schema write accepted, backfill incomplete. The dangerous one, because no error is raised. Make the backfill idempotent and re-runnable, and count expected against actual.
- Cleanup too early. Removing the old grant path before every caller has moved. Grep application code and any policy-as-code repos for the old permission name before the final phase.
- Datastore migration skipped on upgrade. The server errors or refuses to start. Back up the datastore, then run the datastore migration path for the new binary version before starting it.
- Divergent schemas. Multiple SpiceDB instances sharing one datastore share one schema. Separate datastores per region each need the same rollout.
Conditions that would change this design
- Two schema versions must run simultaneously (long blue/green deploys). Additive-only changes with an explicit compatibility window become mandatory, and the cleanup phase waits for the last old caller.
- You need an attributed audit trail. SpiceDB will not provide schema-change history. Record the git commit SHA and operator identity in your deployment log.
- Very large relationship sets. Per-relationship backfill is too slow; batch writes or bulk import, and measure throughput before committing to the window.
- No production-like staging data. The pre-flight check degrades to schema compilation only. Say so explicitly rather than treating it as a full test.
Checking the result
The rollout is done when zed schema read matches the git file, the backfill counts match, and the negative checks pass. Keep the previous schema file — revert is one write, provided you have not already removed relationships that the old schema depended on.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.