<- Back to BlogCode Generation

JSON to Go, Pydantic, and Zod: Code Generators Compared (2026)

Need types from a sample payload?

Use the JSON to Go Generator for structs, the JSON to Pydantic Generator for Python models, or the JSON to Zod Generator for TypeScript runtime validation. Review generated code before treating it as a contract.

A JSON sample is evidence about one payload, not a complete API contract. Code generators turn that evidence into a useful starting point for a language-specific type system. The best generator matches the code that will consume the data and makes uncertainty visible instead of pretending every field is known.

1. Choose by runtime and purpose

GeneratorBest fitWhat it gives you
JSON to GoGo servicesStructs, tags, pointers, and nested types
JSON to PydanticPython APIsBaseModel classes, aliases, and parsing
JSON to ZodTypeScript appsRuntime schemas and inferred types

None can infer fields that never appeared, distinguish a stable integer from an occasional string, or decide whether missing should be optional or invalid. Those decisions belong to the contract owner.

Generation also has a boundary. Types sitting at the transport edge — request bodies, response envelopes, webhook payloads — are ideal candidates: they change whenever the contract changes and carry few invariants of their own. Domain types such as money, quantities, and state machines should stay hand-written, because no generator can know that a balance is never negative or that one date must precede another. Decision rule: generate the data-transfer types, hand-write the domain types, and keep runtime validation at the boundary where the generated code lives.

2. What one sample can and cannot tell you

Collect success, empty, partial, and error responses. A generator can infer a current type, but not whether a field is always present, whether an ID is a UUID, or whether a server sometimes returns null. Generate from representative fixtures with credentials removed.

{
  "id": 42,
  "name": "Ada",
  "tags": ["admin", "billing"],
  "profile": { "timezone": "UTC" }
}

Build the fixture set deliberately: a success response teaches the happy shape, an empty collection proves arrays can be empty, and an error envelope is an entirely different contract that the happy sample never hinted at.

// success - what most people generate from
{ "id": 42, "tags": ["admin"], "profile": { "timezone": "UTC" } }

// empty - the array exists, but was it always one element?
{ "id": 42, "tags": [], "profile": null }

// error - a second contract, absent from the sample above
{ "error": { "code": "not_found", "message": "User 42 does not exist" } }

Model success and error payloads as separate types and branch on a discriminator before parsing, or every error response will fail validation against a model that never saw one. Redact tokens, personal data, and internal hostnames before generating: fixtures get committed, and committed fixtures tend to live forever.

3. JSON to Go structs

Go structs are explicit data-transfer objects. JSON tags preserve wire names when Go naming differs. Pointer fields help when missing must be distinguished from a zero value; value fields are simpler when the API guarantees presence. Adding pointers everywhere makes callers handle nil values without improving correctness.

type Profile struct {
    Timezone string
}

type User struct {
    ID      int
    Name    string
    Tags    []string
    Profile Profile
}

Polymorphic JSON may require custom unmarshalling or a discriminated union design. Generated structs are a starting point, not a reason to ignore API compatibility.

4. Pydantic models

Pydantic combines Python annotations with runtime parsing, which is useful at API boundaries. Pydantic v1 and v2 differ in configuration and serialization, so choose the target version explicitly. Aliases matter when JSON uses camelCase but Python uses snake_case. Decide whether unknown fields are ignored, allowed, or rejected.

from pydantic import BaseModel

class User(BaseModel):
    id: int
    name: str
    tags: list[str]

5. Zod schemas

Zod is executable at runtime. Parse unknown network data with the schema, then infer the TypeScript type from that same schema. An interface alone disappears after compilation and cannot validate a response.

import { z } from "zod";

const UserSchema = z.object({
  id: z.number().int(),
  name: z.string(),
  tags: z.array(z.string())
});

type User = z.infer<typeof UserSchema>;

Refine generated schemas with URL checks, enums, minimum lengths, nullable fields, and an intentional unknown-key policy. Use discriminated unions when a reliable kind field exists.

6. Nullability and optionality: three states, not two

JSON gives every field three possible states: a value, an explicit null, or no key at all. Each target language collapses those states differently, and the collapse is where generated code quietly lies to the rest of the program.

DeclarationValue presentExplicit nullKey absent
Go V stringStoredBecomes ""Becomes ""
Go V *stringStoredStays nilStays nil
Pydantic x: strAcceptedValidation errorError: field required
Pydantic x: str | NoneAcceptedAcceptedError: field required
Pydantic x: str | None = NoneAcceptedAcceptedDefault None
Zod z.string()AcceptedRejectedRejected
Zod .optional()AcceptedRejectedKey omitted from result
Zod .nullable()AcceptedAcceptedRejected
Zod .nullish()AcceptedAcceptedKey omitted from result

Read the Go rows twice. encoding/json leaves a pointer field untouched when the key is absent and stores nil when the value is null, so a pointer separates empty from non-empty but never missing from null. Telling those apart in Go takes deliberate machinery — a presence set, a pointer to a pointer, or a json.RawMessage field that preserves the literal — and it should exist only where business logic genuinely branches on presence.

const PatchSchema = z.object({
  nickname: z.string().nullable().optional()
});

PatchSchema.parse({ nickname: "ada" }); // { nickname: "ada" }
PatchSchema.parse({ nickname: null });  // { nickname: null }
PatchSchema.parse({});                  // {} - key genuinely absent

Pydantic offers the same lever from the other direction: model_fields_set records which keys actually arrived, which is how a PATCH handler distinguishes "leave this alone" from "set it to null". Generators infer optionality from the sample they are given — a field that always appears becomes required, a field containing null becomes nullable, and a payload that demonstrates neither state teaches the generator nothing. Collect fixtures that omit keys and include explicit nulls when your API uses those states: JSON to Pydantic Generator and JSON to Zod Generator.

7. Unions, enums, and polymorphism

Real contracts vary: a status drawn from a fixed set, a result object tagged with a kind, or a field that arrives as either a string or an object. One sample shows a single arm of each variation, so a generator emits one concrete type and the union has to be rebuilt by hand.

PatternJSON SchemaGoPydanticZod
Fixed stringsenumNamed string type plus validationLiteral[...] or Enumz.enum([...])
Tagged variantsoneOf with a discriminatorOne struct per variant plus custom unmarshallingAnnotated[Union[...], Field(discriminator="kind")]z.discriminatedUnion("kind", [...])
Open recordadditionalPropertiesmap[string]anydict[str, T]z.record(z.string(), T)
Value or objectanyOfinterface with a type switchUnion[str, Model]z.union([z.string(), Model])

Prefer an explicit discriminator everywhere you control the contract. Zod's discriminatedUnion checks the tag first and infers the exact variant, while plain z.union tries each option in order and reports the last failure, producing confusing error messages. Pydantic's discriminated unions behave the same way and are markedly faster than broad unions. Go has no tagged-union syntax at all: decode the envelope, switch on the discriminator, then decode the payload into the concrete struct — and make an unknown kind a loud error instead of a silent zero value, because tomorrow's new variant will reach production before your client ships.

If a schema already describes the contract, encode the variants there first: JSON Schema's oneOf with constant tags mirrors the same design, and the JSON Schema Validator is what proves each fixture belongs to the right arm. The schema-first TypeScript path is described in the JSON Schema to TypeScript guide.

8. Numbers, identifiers, and precision

JSON has a single number type in its grammar, and every runtime picks its own representation. That gap is where identifiers get mangled. The canonical hazard: 9007199254740993 (253 + 1) sits past JavaScript's safe integer range, so a plain JSON.parse returns ...992 without warning. Snowflake-style IDs, database BIGINTs, and blockchain nonces all live beyond that line.

Wire valueRiskGoPython / PydanticTypeScript / Zod
42Noneint or int64int, exactz.number().int()
9007199254740993Silent JS roundingint64 is fineint, arbitrary precisionUnsafe as number; send a string or parse with a BigInt-aware reader
19.99 as moneyBinary float roundingfloat64 by default; decimal types for moneyfloat; Decimal needs explicit coercionnumber; integer minor units sidestep the issue
"007"Leading zeros lost if coercedKeep as stringKeep as stringz.string() with a pattern
dec := json.NewDecoder(r)
dec.UseNumber() // numbers arrive as json.Number, exact literal text

var IdSchema = z.string().regex(/^\d{1,20}$/) // big identifiers stay strings

Practical rules. Send identifiers beyond 253 - 1 as strings on the wire. Model money as integer minor units or decimal strings, backed by Decimal in Python and a decimal type in Go rather than binary floats. Python's int never overflows and Go's json.Number preserves the literal until you convert deliberately; TypeScript has neither luxury, so precision must be protected before JSON.parse, not in the type declaration. And remember that JSON Schema's format keywords are annotations — validators ignore them unless configured otherwise.

9. Unknown fields and forward compatibility

The only sustainable evolution rule is: producers add fields, consumers ignore unknown ones. A generated model that rejects unknown keys turns a routine server deployment into a client outage.

BehaviorGoPydanticZodJSON Schema
DefaultIgnores unknown fieldsextra="ignore"Strips unknown keysExtra properties allowed
StrictDecoder.DisallowUnknownFields()extra="forbid".strict()additionalProperties: false
RetainRequires custom decodingextra="allow" exposes extras.passthrough() keeps keysSchema on additionalProperties

All three languages default to permissive behavior, which is the right starting point for consumers: additive changes stay additive. Use strict mode only where you fully own both ends of the payload — request bodies entering your own service, configuration you wrote — and never on a response fetched from a third party that ships new fields weekly.

Schema authors face the sharper decision. additionalProperties: false is a breaking-change machine unless every producer coordinates on it, so add it deliberately, version the contract when you do, and test additive changes against old consumers first — the trade-offs are worked through in the JSON Schema versioning guide. In code, decide the policy per model rather than globally: a strict parser for your own requests and a tolerant parser for foreign responses solve different problems, and one flag rarely fits both. Express the policy in schema too, with the JSON Schema Validator checking fixtures against it in CI.

10. Production workflow

  1. Collect several real payloads and redact credentials or personal data.
  2. Generate a first model for the language your service uses.
  3. Review naming, optionality, nullability, numeric ranges, and unknown fields.
  4. Commit models beside contract tests, not as unreviewed generated output.
  5. Validate success and failure payloads in CI.
  6. Regenerate only when the input contract intentionally changes.

The JSON to Go Generator, JSON to Pydantic Generator, and JSON to Zod Generator run locally in the browser. Use representative fixtures rather than live secrets.

Schema-first when several languages share the contract. Generate a JSON Schema from representative samples with the JSON to JSON Schema Generator, review optionality, enums, formats, and the unknown-key policy by hand, then commit it as the source of truth. Go, Python, and TypeScript each derive their models from that one reviewed schema in CI, which keeps the three declarations synchronized instead of drifting across independent sample files.

# illustrative pipeline - flags vary by tool
python -m datamodel_code_generator --input contract/schema.json --output app/models.py
npx json-schema-to-zod contract/schema.json > src/contract.generated.ts
git diff --exit-code app/models.py src/contract.generated.ts   # fail on drift

Round-trip tests. For every fixture: parse it with the generated model, serialize it back, parse the result again, and compare against a canonicalized original with sorted keys and stable number formatting. Round trips catch aliases that do not survive serialization, serializers that drop unknown fields, and numeric coercion that quietly changes values — defects that type-checking alone never surfaces. Regenerate only when the reviewed contract changes, so a diff appearing in CI always means a real contract change rather than tooling noise. Keep fixture JSON well-formed before generation starts: malformed samples fossilize straight into types.

11. Common mistakes

  • Making every field optional: the type becomes easy to compile but hard to use safely.
  • Confusing null with missing: clients may need different behavior for each.
  • Trusting inferred numbers: IDs can exceed safe JavaScript precision or arrive as strings.
  • Ignoring unknown fields: additive API changes can go unnoticed.
  • Using interfaces as validation: interfaces do not inspect runtime data.
  • Generating from one happy path: arrays, errors, and empty states reveal the real contract.
  • Using one model for requests and responses: responses must tolerate fields the server adds later, while your own request bodies can afford strict validation.
  • Regenerating on every build: generated files become noisy, unreviewed output; regenerate on contract changes and review the diff.
  • Trusting format annotations: format: uuid and similar keywords are not enforced unless the validator is configured to enforce them.

Validate fixtures with the JSON Validator and keep a human-owned contract around generated code.

Conclusion

Code generation saves typing, not design decisions. Choose Go for explicit structs, Pydantic for Python boundary validation, and Zod when TypeScript needs runtime parsing. Generate locally, review uncertainty, and test variations. When more than one language consumes the same contract, promote a reviewed schema to the source of truth and generate from it in CI — the three generators compared here remain the fastest route from a single sample to a working model.

Frequently Asked Questions

Which generator should I use?
Use JSON to Go for Go services, JSON to Pydantic for Python models, and JSON to Zod when TypeScript needs runtime validation.
Can a generator create a complete API contract?
No. A sample cannot reveal absent fields, future variants, or business constraints.
Should generated fields be optional?
Only when the API can omit them or missing data is valid.
What is the difference between an interface and Zod?
A TypeScript interface is removed at runtime; a Zod schema can parse and reject data.
When should Go use pointers?
Use pointers when missing and zero values must be distinguished.
Why do Pydantic aliases matter?
They let Python naming differ from the JSON wire name while preserving serialization behavior.
Can generated code tell a missing field apart from null?
Not by default. Presence has to be modeled explicitly: pointers or presence sets in Go, defaults against required fields in Pydantic, and separate optional and nullable rules in Zod.
Should I generate types from JSON Schema instead of a sample?
When a reviewed schema exists, yes. Schemas carry optionality, enums, and constraints that no single payload can show, and every language can generate from the same schema.