Skip to content

Schema Evolution

Firestore is schemaless, so documents written under an older schema linger unchanged. This page covers how to evolve a schema safely and keep reads returning the current shape — without a data migration.

Because reads are casts, a field you add to the schema later is typed as present but is undefined at runtime on pre-migration documents. Rewriting every stored document is expensive and often unnecessary. Instead, normalize on read.

The readConverter is the seam that fixes drift: it runs on every read, so normalize the raw body into the current schema shape there and every read comes back current — without a data migration.

Best practice: treat the readConverter as the place to coerce a stored document into the current schema shape. A targeted backfill is cheapest — spread defaults before the stored data so new fields fall back and existing values win:

const userReadConverter: ReadConverter<User> = snapshot => {
const data = snapshot.data();
// `status` was added to the schema later; older docs lack it.
return { status: 'active', ...data } as User;
};

For full coercion across every schema revision, parse the raw body through the read schema so defaults backfill and types coerce on every read. Give evolving fields a .default(...) so pre-migration documents parse cleanly:

// userSchema gained: status: z.enum(['active', 'archived']).default('active')
const userReadConverter: ReadConverter<User> = snapshot =>
userSchema.parse(snapshot.data()) as User;

Giving fields a .default(...) for read-side backfill is safe for writes: defaults are applied on create but never injected on a partial update, so a later update(id, { … }) that omits a defaulted field leaves the stored value untouched (see Schema Validation).

Full-parse normalization is heavier than the default cast (a full Zod parse on every read), so reserve it for collections where drift is likely — it deliberately trades read speed for a self-healing read shape. It composes with the built-in createMillisTimestampConverter: run the timestamp mapper first, then parse. And because normalization already happened on the way out, validate() / safeValidate() at a trust boundary become pure assertions that pre-migration documents still pass.