How to Describe Data with JSON Schema and OAS: The Definitive Guide to Precise API Data Modeling

How to Describe Data with JSON Schema and OAS: The Definitive Guide to Precise API Data Modeling

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:

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

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

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.

Exit mobile version