JSON Formatter Hub

Free online JSON formatter, validator, and developer tools.

REST API JSON Best Practices: Design Patterns Every Developer Should Know

A well-designed JSON API is a joy to consume. A poorly designed one creates bugs, confusion, and endless Slack threads asking "what does this field actually mean?" The decisions you make about naming, error formats, pagination, and date handling ripple through every client that ever calls your API. This guide covers the patterns that separate maintainable APIs from painful ones, with concrete JSON examples for each.

1. Field Naming Conventions

Pick snake_case or camelCase and be consistent across every endpoint. Most REST APIs use one of these. The worst choice is mixing them.

snake_case (most common in Python/Ruby APIs)

{
  "user_id": 42,
  "first_name": "Alice",
  "last_login_at": "2025-05-01T10:30:00Z",
  "is_active": true
}

camelCase (most common in JavaScript/Node APIs)

{
  "userId": 42,
  "firstName": "Alice",
  "lastLoginAt": "2025-05-01T10:30:00Z",
  "isActive": true
}

Both are fine. The critical rule: never mix. A response body with user_id alongside firstName is a maintenance nightmare.

Naming rules that hold regardless of case style

// IDs from distributed systems (Snowflake IDs, etc.) should be strings
{
  "id": "7823648237462834762",   // string โ€” safe in all languages
  "order_number": 10045          // small integer โ€” fine as number
}

2. Response Structure

Wrap your response in a consistent envelope. Clients should be able to predict the shape of every response without reading the documentation.

Single resource response

{
  "data": {
    "id": "usr_42",
    "name": "Alice",
    "email": "alice@example.com",
    "created_at": "2024-01-15T08:00:00Z"
  }
}

Collection response

{
  "data": [
    { "id": "usr_42", "name": "Alice" },
    { "id": "usr_43", "name": "Bob" }
  ],
  "meta": {
    "total": 248,
    "page": 1,
    "per_page": 20
  }
}

The data wrapper lets you add meta, links, or other top-level fields later without breaking existing clients. If you return a bare array at the top level, you can never add pagination metadata without a breaking change.

Why bare arrays are a trap

// BAD: bare array โ€” can never add metadata without breaking clients
[
  { "id": 1, "name": "Alice" },
  { "id": 2, "name": "Bob" }
]

// GOOD: wrapped โ€” extensible
{
  "data": [
    { "id": 1, "name": "Alice" },
    { "id": 2, "name": "Bob" }
  ]
}

3. Error Response Format

Error responses are where the most inconsistency lives. Some APIs return HTML error pages, some return plain strings, some return JSON with completely different keys per endpoint. Pick a format and enforce it via middleware so every error โ€” including 500s โ€” returns the same shape.

Recommended error format

{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Request body validation failed",
    "details": [
      {
        "field": "email",
        "message": "Must be a valid email address",
        "value": "not-an-email"
      },
      {
        "field": "age",
        "message": "Must be a positive integer",
        "value": -5
      }
    ],
    "request_id": "req_8f3k2j9s"
  }
}

Key fields explained:

HTTP status code + error code pairing

HTTP Status Error Code When to Use
400VALIDATION_FAILEDRequest body or query params failed validation
401UNAUTHORIZEDNo valid authentication credentials
403FORBIDDENAuthenticated but not permitted
404NOT_FOUNDResource does not exist
409CONFLICTOptimistic lock failure, duplicate key
422UNPROCESSABLESyntactically valid but semantically wrong
429RATE_LIMITEDToo many requests
500INTERNAL_ERRORUnexpected server error

Always pair the HTTP status code with a machine-readable code field. The status tells the client the category; the code tells it the specific cause.

4. Pagination

Three pagination patterns exist. Each has tradeoffs.

Offset pagination

// Request: GET /users?page=3&per_page=20
{
  "data": [ ... ],
  "meta": {
    "total": 248,
    "page": 3,
    "per_page": 20,
    "total_pages": 13
  },
  "links": {
    "first": "/users?page=1&per_page=20",
    "prev": "/users?page=2&per_page=20",
    "next": "/users?page=4&per_page=20",
    "last": "/users?page=13&per_page=20"
  }
}

Pros: Can jump to any page, easy to implement.
Cons: Skips or duplicates records if the dataset changes mid-pagination. Slow on large tables (SQL OFFSET 10000 scans 10,000 rows).

Cursor pagination

// Request: GET /events?limit=20&after=cursor_abc123
{
  "data": [ ... ],
  "meta": {
    "has_next_page": true,
    "has_prev_page": true
  },
  "links": {
    "next": "/events?limit=20&after=cursor_xyz789",
    "prev": "/events?limit=20&before=cursor_abc123"
  }
}

Pros: Stable โ€” new inserts don't cause duplicates or skips. Efficient โ€” keyset pagination uses an index.
Cons: Can't jump to page 7. Total count is expensive to compute.

Use cursor pagination for feeds, timelines, and any high-write collection. Use offset pagination for admin tables where users need to jump to specific pages.

Include pagination links in the response

Always include next and prev links so clients can paginate without constructing URLs themselves. This is the HATEOAS principle applied minimally and practically.

5. Dates and Times

Use ISO 8601 with UTC timezone for all timestamps. Full stop.

// GOOD
{
  "created_at": "2025-05-01T10:30:00Z",       // UTC
  "scheduled_at": "2025-06-15T14:00:00+05:30", // with offset if timezone matters
  "birth_date": "1990-07-22"                    // date-only when time is irrelevant
}

// BAD
{
  "created_at": "May 1, 2025",        // ambiguous, not machine-parseable
  "created_at": 1746094200,           // Unix timestamp โ€” readable but not self-documenting
  "created_at": "01/05/2025 10:30"    // is that May 1 or January 5?
}

Rules:

Duration fields

{
  "timeout_seconds": 30,          // explicit unit in the field name
  "retry_interval_ms": 500,       // milliseconds when precision matters
  "session_duration": "PT1H30M"   // ISO 8601 duration format
}

Always encode the unit in the field name for numeric durations. timeout: 30 is seconds? milliseconds? minutes? You'll get it wrong and break something.

6. Null vs Missing Fields

null and a missing field mean different things. Be explicit about which you use and why.

// Field present and null โ€” the value exists, it was explicitly set to null
{
  "name": "Alice",
  "middle_name": null
}

// Field absent โ€” you don't know or it doesn't apply
{
  "name": "Alice"
  // middle_name not included
}

The practical rule most APIs follow:

Never use empty string as null

// BAD โ€” using empty string to mean "no value"
{
  "company": ""
}

// GOOD โ€” null is explicit
{
  "company": null
}

// GOOD โ€” omit if not applicable
{}

Empty string is a valid value โ€” it means a string of zero characters. It should never be used as a sentinel for "no value."

Partial responses and sparse fieldsets

// Request: GET /users/42?fields=id,name,email
{
  "data": {
    "id": "usr_42",
    "name": "Alice",
    "email": "alice@example.com"
    // other fields omitted by client request, not null
  }
}

When supporting sparse fieldsets, document clearly that missing fields are excluded by the client, not null.

7. Versioning

Plan for breaking changes from day one. The three most common approaches:

URL versioning (most common)

GET /v1/users
GET /v2/users

Simple, explicit, cacheable. The version is visible in every log entry and URL. The downside is that clients have to update URLs when migrating versions.

Header versioning

GET /users
Accept: application/vnd.myapi.v2+json

Keeps URLs clean but requires clients to set headers correctly and makes versioning invisible in browser address bars.

What counts as a breaking change

Breaking Non-breaking (safe to ship)
Removing a fieldAdding a new optional field
Renaming a fieldAdding a new endpoint
Changing a field's typeAdding a new HTTP method to an endpoint
Changing error codesMaking a required field optional
Changing auth requirementsAdding new valid enum values (controversial)

8. Anti-Patterns to Avoid

Anti-pattern: Using HTTP 200 for errors

// BAD โ€” client has to parse body to detect failure
HTTP 200 OK
{
  "success": false,
  "error": "User not found"
}

// GOOD โ€” HTTP status communicates outcome
HTTP 404 Not Found
{
  "error": {
    "code": "NOT_FOUND",
    "message": "User with id usr_999 does not exist"
  }
}

When you return 200 for errors, every client has to add custom error-detection logic. HTTP status codes are the contract โ€” use them.

Anti-pattern: Inconsistent ID types

// BAD โ€” some endpoints return numeric IDs, others return strings
GET /users/42         โ†’ { "id": 42 }
GET /orders/42        โ†’ { "id": "ord_42" }

// GOOD โ€” consistent ID format everywhere
GET /users/usr_42     โ†’ { "id": "usr_42" }
GET /orders/ord_42    โ†’ { "id": "ord_42" }

Anti-pattern: Encoding data in field names

// BAD โ€” field names change with data; impossible to handle generically
{
  "price_usd": 10.99,
  "price_eur": 9.50,
  "price_gbp": 8.75
}

// GOOD โ€” structure encodes the variation
{
  "prices": [
    { "currency": "USD", "amount": 10.99 },
    { "currency": "EUR", "amount": 9.50 },
    { "currency": "GBP", "amount": 8.75 }
  ]
}

Anti-pattern: Deeply nested responses

// BAD โ€” three levels deep to get to the data you want
{
  "response": {
    "body": {
      "result": {
        "users": [...]
      }
    }
  }
}

// GOOD โ€” data at predictable depth
{
  "data": [...]
}

Anti-pattern: Mixed array and object responses for the same endpoint

// BAD โ€” sometimes returns object, sometimes array
GET /users/42  โ†’ { "id": 42, "name": "Alice" }
GET /users     โ†’ { "id": 1, "name": "..." }   // forgetting to wrap in array

// GOOD โ€” collections always arrays, resources always objects
GET /users/42  โ†’ { "data": { "id": 42 } }
GET /users     โ†’ { "data": [ { "id": 42 } ] }

Anti-pattern: Exposing internal implementation details

// BAD โ€” reveals DB column names, table structure, ORM internals
{
  "tbl_usr_id": 42,
  "__v": 0,
  "_id": "507f1f77bcf86cd799439011",
  "mongo_created": 1746094200
}

// GOOD โ€” API shape is independent of storage
{
  "id": "usr_42",
  "created_at": "2025-05-01T10:30:00Z"
}

Summary: The API Design Checklist

Concern Recommendation
Field namingsnake_case or camelCase โ€” pick one and enforce it everywhere
Response shapeWrap in data key; never bare arrays at top level
Error formatConsistent envelope: code, message, details, request_id
HTTP status codesUse them correctly โ€” 200 means success
PaginationCursor for feeds; offset for admin; always include links
DatesISO 8601 with UTC (Z suffix) always
Null handlingnull = known absence; omit = not applicable
IDsStrings for large IDs; consistent prefix per resource type
VersioningURL versioning for simplicity; plan for breaking changes

Well-designed JSON APIs aren't just easier to consume โ€” they're easier to maintain. When the response shape is consistent, clients are more resilient, debugging is faster, and adding new features doesn't require coordinating SDK changes across every consumer.

Further Reading

Use the JSON Formatter Hub to format, validate, and fix your JSON right now โ€” free and fully browser-based.