Skip to content

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 uint to int halves 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.

const PersonWire = fingerprinted(compile(Person));
const bytes = PersonWire.encode(person); // 11 bytes: 3 fingerprint + 8 payload
PersonWire.decode(bytes); // throws if the schema changed

DecodeError: 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.

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.

Wire3 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.

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.objectz.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.

BytesOdds of a silent wrong-value decode
1~1 in 958
2~1 in 245,000
3~1 in 63,000,000
4better 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.

codec.fingerprint; // Uint8Array, a fresh copy every read
codec.fingerprintHex; // "7236d1", stable and safe to reuse

Put 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.

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.

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.

  • 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.