Fingerprinting
Three of the six tested schema edits can silently corrupt data:
- Renaming two fields can swap their values because field order is derived from each name.
- Widening
uinttointhalves the number, because ZigZag doubles the magnitude. - Adding an enum member shifts the index of each member at or after it.
None of these changes necessarily throws. The decoder can return a plausible object with incorrect data.
The fix
Section titled “The fix”const PersonWire = fingerprinted(compile(Person));
const bytes = PersonWire.encode(person); // 11 bytes: 3 fingerprint + 8 payloadPersonWire.decode(bytes); // throws if the schema changedDecodeError: Payload was written by a different schema (expected fingerprint 7236d1)
Use it for anything stored, queued, or crossing a version boundary. Bare payloads are for endpoints pinned to one schema at deploy time.
Why it is not the default
Section titled “Why it is not the default”The reason is payload size. The fingerprint increases Person from 8 to 11 bytes, making it larger than Avro at 8 bytes and SchemaPack at 9. The compact default requires care: if you remember only one safety rule, fingerprint stored or version-crossing payloads.
| Wire | 3 bytes (default) |
| Encode | ~1.5 ns |
| Decode | ~6.3 ns |
| Bundle | +379 gzip, only for importers |
The decoder reads fingerprint bytes in a loop. It does not call reader.bytes(n), which returns a new subarray and costs about 24 ns in the benchmark.
What changes the fingerprint
Section titled “What changes the fingerprint”It hashes the canonical wire structure, so only byte-moving changes reissue it.
Reissues: adding, removing, or renaming a field · changing a type · required ↔ optional · adding or removing an enum member · z.int() ↔ z.int().nonnegative() · reordering a tuple.
Does not reissue: reordering property declarations · adding any refinement · z.object ↔ z.strictObject · switching vendors · changing a z.codec()’s conversion functions.
The last two cases are intentional. The signature excludes rejectUnknown, so equivalent schemas agree even when validators handle extra properties differently. Conversion functions also live outside the wire shape, so changing them does not invalidate existing payloads.
| Bytes | Odds of a silent wrong-value decode |
|---|---|
| 1 | ~1 in 958 |
| 2 | ~1 in 245,000 |
| 3 | ~1 in 63,000,000 |
| 4 | better still |
fingerprinted(compile(Person), { bytes: 2 });Performance is effectively the same for all four widths. Use the default 3 bytes for stored data. One- or two-byte fingerprints are only appropriate for tightly controlled protocols that accept the higher collision risk.
The format version and fingerprint width are mixed into the low byte of the FNV-1a seed. This is required because a seed bit at position 8 or above cannot affect the low output byte. Mixing them higher would make a one-byte fingerprint ignore both values.
Carrying the fingerprint separately
Section titled “Carrying the fingerprint separately”codec.fingerprint; // Uint8Array, a fresh copy every readcodec.fingerprintHex; // "7236d1", stable and safe to reusePut those in a Kafka header, a column, or a filename and keep the payload bare. This is also the workaround for the async gap.
fingerprint returns a copy so callers cannot mutate the codec’s internal bytes. fingerprintHex is immutable and can be used as a Map key.
It is not an authentication tag
Section titled “It is not an authentication tag”The digest is unkeyed: anyone with the schema can compute or forge it. It detects schema mismatches but is not a message authentication code. Sign or encrypt payloads when you need authenticity.
The format version rides along
Section titled “The format version rides along”WIRE_FORMAT_VERSION is included in the fingerprint seed instead of stored as a separate byte. A decoder using a different wire-format version therefore derives a different fingerprint and rejects the payload. See Schema Evolution.
Limitations
Section titled “Limitations”- Requires a
compile()codec.fingerprinted(m.object({...}))throws — raw wire is unframed by design. - Does not compose with async validation. Carry it out of band instead.
- Detects, never resolves. Reading the payload anyway is Schema Evolution.