| name | auto-serialization |
| description | Serialization discipline: decimal precision, timezone-aware datetimes, forwards-compatible enums, null vs missing distinction, and deterministic output. Corrects float financial values, naive datetimes, crashing enum deserialization, and non-canonical serialization. Use when defining serializable types, API payloads, job queue messages, or database JSON columns. Triggers: serialize, deserialize, serde, json, toml, pydantic, zod, schema, payload, precision, float, decimal, timestamp, timezone, enum, variant, unknown, forwards compatible, canonical, deterministic. |
Serialization — What Claude Gets Wrong
You serialize data like it will only ever be read by the exact code that wrote it. In reality, serialized data crosses service boundaries, survives schema changes, gets cached, and is read by code versions that don't exist yet.
The Five Rules
- Financial/precise values → string or decimal type, never float
- Datetimes → always UTC, always with timezone suffix
- Enums → unknown variants must not crash deserialization
- New fields → must be optional or defaulted for old data
- Canonical form → same input produces same bytes
Anti-Patterns You Default To
| Anti-pattern | Example | Fix |
|---|
| Float for money | price: f64 | #[serde(with = "rust_decimal::serde::str")] price: Decimal |
| Naive datetime | dt.isoformat() → "2026-03-06T10:00:00" | dt.astimezone(UTC).isoformat() → "2026-03-06T10:00:00+00:00" |
| Crashing enum | z.enum(["low","high"]) rejects "critical" | Add catchall: #[serde(other)] Unknown / .catch("unknown") |
| No null vs missing | field: Option<T> = None conflates both | #[serde(skip_serializing_if = "Option::is_none")] + distinguish |
| Non-deterministic | HashMap key order varies per run | Sort keys, normalize decimals, use BTreeMap |
| Implicit format | No docs on wire format | Doc comment with example JSON |
Decimal Precision
#[derive(Serialize)]
struct Trade { price: f64 }
use rust_decimal::Decimal;
#[derive(Serialize)]
struct Trade {
#[serde(with = "rust_decimal::serde::str")]
price: Decimal,
}
const amount = z.number();
const amount = z.string().regex(/^\d+(\.\d+)?$/);
class Trade(BaseModel):
price: float
from decimal import Decimal
class Trade(BaseModel):
price: Decimal
class Config:
json_encoders = {Decimal: str}
Timezone-Aware Datetimes
pub created_at: NaiveDateTime
pub created_at: DateTime<Utc>
{"ts": datetime.now().isoformat()}
from datetime import datetime, timezone
{"ts": datetime.now(timezone.utc).isoformat()}
Forwards-Compatible Enums
#[derive(Deserialize)]
enum Status { Pending, Running, Completed, Failed }
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
enum Status {
Pending, Running, Completed, Failed,
#[serde(other)]
Unknown,
}
const Status = z.enum(["pending", "running", "completed"]);
function parseStatus(raw: string): Status | "unknown" {
const known = ["pending", "running", "completed"] as const;
return known.includes(raw as any) ? (raw as Status) : "unknown";
}
class Status(str, Enum):
PENDING = "pending"
class Status(str, Enum):
PENDING = "pending"
UNKNOWN = "unknown"
@classmethod
def _missing_(cls, value):
return cls.UNKNOWN
Null vs Missing
These are different: {"name": null} means "explicitly no name." {} means "name was not provided." Your types should reflect this.
#[derive(Serialize, Deserialize)]
struct Update {
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
}
Deterministic Output
When serialized data is hashed, compared, or deduplicated, output must be canonical:
use std::collections::HashMap;
#[derive(Serialize)]
struct Report { metadata: HashMap<String, String> }
use std::collections::BTreeMap;
#[derive(Serialize)]
struct Report { metadata: BTreeMap<String, String> }
Format Documentation
Every serializable type should have a doc comment showing the wire format:
#[derive(Serialize)]
struct PriceUpdate { }