Once I’ve mapped out an API’s goals with OpenAPI, the very next thing I focus on is data. Every request and every response is, at its core, data flowing back and forth. If I don’t describe that data precisely, everything downstream — validation, documentation, generated SDKs, mock servers — falls apart. This is where JSON Schema comes in, and in this article, I want to walk you through exactly how I use it inside OpenAPI documents to describe data with real precision.
I’ll go deep here, because this is genuinely the part of API design most people get sloppy with. A vague schema is worse than no schema, because it gives a false sense of safety.
What JSON Schema Actually Is
JSON Schema is a vocabulary for describing the structure, types, and constraints of JSON data. It’s a separate specification from OpenAPI, maintained independently, but OpenAPI adopted it (with a few tweaks) as its native way of describing request bodies, response bodies, and parameters.
I like to think of JSON Schema as a contract at the data level, the same way OpenAPI’s paths are a contract at the operation level. When I write:
type: object
properties:
email:
type: string
format: email
required:
- email
I’m not just describing what the data looks like. I’m making a promise: “if you send me data, it must satisfy this shape, or I will reject it.” That promise is what makes automated validation, contract testing, and reliable SDK generation possible.
OpenAPI 3.0 vs 3.1: A Critical Difference
Before going further, I have to explain something that trips up a lot of people. OpenAPI 3.0.x uses a modified subset of JSON Schema — it does NOT support the full spec. For example, in 3.0.x you can’t use type as an array (like type: ["string", "null"]), and there are quirks around nullable.
OpenAPI 3.1.x, however, aligned itself fully with JSON Schema 2020-12. This means in 3.1.x, you get the entire JSON Schema vocabulary, including things like $dynamicRef, prefixItems, and proper nullable typing via type arrays.
I always check which version I’m targeting before I start writing schemas, because a schema that works in 3.1 might silently be invalid — or interpreted differently — in 3.0. If you’re starting a brand-new API today, I’d generally recommend 3.1.x for this exact reason.
The Core Building Blocks of JSON Schema
Let me walk through every essential keyword I use regularly, with real explanations of when and why.
Types
type: string
type: number
type: integer
type: boolean
type: object
type: array
type: "null"
I’m deliberate about choosing integer over number whenever decimals genuinely don’t make sense — like a quantity field for items in a cart. This single choice prevents a whole category of bugs where someone sends 2.5 items.
String Constraints
type: string
minLength: 3
maxLength: 50
pattern: "^[A-Za-z0-9_-]+$"
format: email
The format keyword is powerful but I always remember it’s advisory in most validators unless you explicitly enable format validation. Common formats I use constantly:
emaildate(YYYY-MM-DD)date-time(RFC 3339)uriuuidhostnameipv4/ipv6
Number Constraints
type: number
minimum: 0
maximum: 1000000
exclusiveMinimum: 0
multipleOf: 0.01
I use multipleOf: 0.01 constantly for currency fields to guard against sub-cent values sneaking into a price field. It’s a small detail, but it prevents real financial bugs.
Object Constraints
type: object
properties:
name:
type: string
age:
type: integer
required:
- name
additionalProperties: false
additionalProperties: false is one of the most underused settings I see. Without it, your schema only validates the fields you do describe — it says nothing about extra, unexpected fields sneaking in. I set this explicitly on almost every request body schema, because I want strict validation on data coming into my API. I’m more relaxed about it on response schemas, since adding new fields to responses later shouldn’t break existing consumers (this is part of designing for backward compatibility, which I’ll touch on in the components article).
Array Constraints
type: array
items:
type: string
minItems: 1
maxItems: 20
uniqueItems: true
I always set minItems and maxItems on arrays that come from user input. An unbounded array from a client is a subtle denial-of-service risk — someone could send an array with a million items and choke your validation or database layer.
Composition Keywords: oneOf, anyOf, allOf, not
This is where JSON Schema becomes genuinely powerful, and also where I see the most confusion.
allOf — Combine Everything
allOf:
- $ref: '#/components/schemas/BaseEntity'
- type: object
properties:
email:
type: string
I use allOf to build composition — take a base schema and extend it. It’s the closest thing JSON Schema has to inheritance.
oneOf — Exactly One Match
oneOf:
- $ref: '#/components/schemas/CreditCardPayment'
- $ref: '#/components/schemas/BankTransferPayment'
I reach for oneOf when a field can genuinely be one of several distinct, mutually exclusive shapes — like different payment method types. This is also where I introduce a discriminator, which I’ll explain next.
anyOf — At Least One Match
I use anyOf far less often, typically for validation rules where multiple optional criteria can independently apply — like “must match at least one of these two patterns.”
not — Must Not Match
not:
required:
- deprecatedField
I use not sparingly, usually to explicitly forbid a legacy field from appearing in new payloads.
Discriminators: Making Polymorphism Usable
When I use oneOf, code generators (and human readers) need a way to figure out which schema a given object actually is. That’s what discriminator solves.
Payment:
oneOf:
- $ref: '#/components/schemas/CreditCardPayment'
- $ref: '#/components/schemas/BankTransferPayment'
discriminator:
propertyName: type
mapping:
credit_card: '#/components/schemas/CreditCardPayment'
bank_transfer: '#/components/schemas/BankTransferPayment'
Now, any consumer or tool reading this schema knows: look at the type field in the payload, and use its value to pick the right schema. Without a discriminator, oneOf is genuinely difficult for generated SDKs to handle correctly.
Enums: Restricting Values Explicitly
status:
type: string
enum:
- pending
- processing
- shipped
- delivered
- cancelled
Whenever a field has a known, finite set of valid values, I always use enum. This does two things: it validates incoming data automatically, and — just as importantly — it documents the allowed values directly in the schema, so nobody has to go digging through backend code to find out what statuses exist.
Nullable Fields: The 3.0 vs 3.1 Trap
In OpenAPI 3.0.x:
status:
type: string
nullable: true
In OpenAPI 3.1.x (true JSON Schema style):
status:
type: ["string", "null"]
I make a note of this every single time because mixing the two styles in the same document based on muscle memory from a previous project is one of the most common schema bugs I’ve had to debug in code review.
Describing Real-World Complex Data
Let me walk through a full, realistic example — an Order schema with nested objects, arrays, enums, and composition, exactly how I’d write it for a production API.
components:
schemas:
Order:
type: object
required:
- id
- status
- items
- total
properties:
id:
type: string
format: uuid
readOnly: true
status:
type: string
enum: [pending, processing, shipped, delivered, cancelled]
items:
type: array
minItems: 1
items:
$ref: '#/components/schemas/OrderItem'
total:
type: number
format: double
minimum: 0
multipleOf: 0.01
shippingAddress:
$ref: '#/components/schemas/Address'
createdAt:
type: string
format: date-time
readOnly: true
OrderItem:
type: object
required:
- productId
- quantity
- unitPrice
properties:
productId:
type: string
quantity:
type: integer
minimum: 1
unitPrice:
type: number
minimum: 0
multipleOf: 0.01
Address:
type: object
required:
- line1
- city
- postalCode
- country
properties:
line1:
type: string
maxLength: 100
line2:
type: string
maxLength: 100
city:
type: string
postalCode:
type: string
country:
type: string
pattern: "^[A-Z]{2}$"
Notice readOnly: true on id and createdAt. This tells consumers: “you’ll receive this field in responses, but you should never try to send it in a request.” It’s a small annotation that prevents a whole class of confused bug reports from API consumers.
Validating Examples Against Your Schema
I never trust a schema I haven’t tested against real example payloads. I typically keep example JSON files next to my OAS document and run them through a validator (many CLI tools exist for this, and I keep example validation scripts in some of my repositories at github.com/aw-junaid) before I consider a schema “done.” A schema that looks correct but fails against a real payload is worse than useless — it erodes trust in the whole document.
Common Data-Modeling Mistakes I Watch For
- Overusing
type: objectwith no properties. This describes nothing. If you can define the shape, define it. - Forgetting
required. A field with norequiredarray is silently optional, even if your business logic assumes it’s always present. - Using strings for everything. Dates as plain strings without
format: date-time, numbers as strings — this kills validation power and confuses type-safe SDK generation. - Not bounding arrays and strings. Missing
maxLengthandmaxItemsis a real security and stability gap, not just a style nitpick. - Duplicating schemas instead of referencing them. If
Addressshows up in three different places with slightly different field names each time, you’ve created a maintenance nightmare. This is exactly the problem the next article in this series solves — reusable components in OAS.
Where This Fits in the Bigger Picture
Precise data modeling is the foundation everything else stands on: your security definitions, your component reuse strategy, your validation middleware, your generated client libraries. Get this part sloppy, and every layer built on top inherits that sloppiness.
Key Takeaways
- JSON Schema is the vocabulary OpenAPI uses to describe data precisely — treat it as a contract, not decoration.
- Know whether you’re on OAS 3.0.x or 3.1.x; nullable handling and JSON Schema compatibility differ meaningfully.
- Use
required,additionalProperties: false, and tight constraints (minLength,maximum,multipleOf) to make your schemas actually enforce something. - Use
oneOfwithdiscriminatorfor real polymorphism; useallOffor composition. - Always test your schemas against real example payloads before calling them finished.
Data modeling is unglamorous work, but it’s the part of API design that pays back the most over time. A precise schema today saves you from ambiguous bug reports, broken integrations, and painful breaking changes six months from now.