Conventions, not rules
JSON's specification says nothing about how to design data — only how to write it. These are the conventions that hold up in production, and the mistakes that are expensive to reverse once an API has consumers.
Key naming
Pick one convention and apply it without exception:
- camelCase — conventional for JavaScript and TypeScript APIs; no conversion needed client-side.
- snake_case — conventional in Python, Ruby, and PostgreSQL ecosystems.
Neither is superior. What causes real harm is mixing them, which forces every consumer to remember which endpoint uses which.
A few rules that are not merely stylistic:
{
"userId": 42,
"isActive": true,
"createdAt": "2026-07-29T10:45:00Z",
"itemCount": 3
}
- No spaces or hyphens in keys. Legal JSON, but they force bracket access (
obj["user-id"]) in most languages, and break dot notation. - Do not start keys with a digit, for the same reason.
- Prefix booleans with
is,has, orcan—isActivereads unambiguously whereactivedoes not. - Plural keys for arrays —
roles, notrole. - Avoid reserved-ish names such as
type,class, andidwhere a more specific term exists.
Dates
Use ISO 8601 in UTC, always:
{ "createdAt": "2026-07-29T10:45:00Z" }
It is unambiguous, sorts correctly as a plain string — a genuinely useful property — is readable by humans, and is parsed natively everywhere.
Avoid Unix timestamps in public APIs: 1785062700 is ambiguous about seconds versus milliseconds, and unreadable in a log. Never use locale-dependent formats — 03/04/2026 is 3 April in London and 4 March in New York.
For a date with no time component, use "2026-07-29". If local time genuinely matters — a calendar event — send the offset ("2026-07-29T10:45:00+01:00") or a separate IANA timezone field ("Europe/London"), because an offset alone does not survive daylight-saving transitions.
Money
Never use a float. JSON numbers are IEEE 754 doubles, and 0.1 + 0.2 === 0.30000000000000004. Over enough transactions those errors accumulate into real discrepancies.
{ "amount": 1099, "currency": "GBP", "scale": 2 }
{ "amount": "10.99", "currency": "GBP" }
Integer minor units are the safest and are what payment processors use. A decimal string is acceptable when the consumer has a decimal type. Either way, always include an explicit currency code — a bare number is not a monetary amount.
Large integers
Integers above 2^53 lose precision when parsed as doubles:
JSON.parse('{"id": 9007199254740993}').id // → 9007199254740992
64-bit database IDs and snowflake identifiers exceed this routinely. Send them as strings:
{ "id": "9007199254740993" }
Twitter/X hit this publicly and now returns both id and id_str. The failure is silent, which is what makes it dangerous.
null versus absent versus empty
Three states that are easy to confuse:
"manager": null— the field applies, the value is genuinely unknown or empty"manager": ""— an empty string, which is a value, not an absence- key absent — the field does not apply to this record
Pick a convention, document it, and stick to it. A common one: use null for known-empty, omit the key for not-applicable, and never use "" to mean absent.
For collections, prefer [] over null — a client can iterate an empty array without a guard, which removes a whole class of null checks.
Response structure
Wrap collections in an object. A top-level array leaves nowhere to add pagination or metadata later without breaking every consumer:
{
"data": [ { "id": 1 }, { "id": 2 } ],
"pagination": { "page": 1, "perPage": 20, "total": 137 }
}
Keep the shape stable. A field should not be a string sometimes and an array other times. Static clients cannot handle it.
Do not nest more than three or four levels. Deep nesting is hard to read, hard to query, and usually means a flatter model or a separate endpoint is called for.
Use a consistent error shape across every endpoint:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "The request could not be processed.",
"details": [
{ "field": "email", "issue": "must be a valid email address" }
]
}
}
A stable machine-readable code matters more than the human-readable message — clients branch on the code, and messages get rewritten.
Versioning
Additive changes are safe. Removing or renaming a field, or changing its type, is breaking. Plan for it before the first consumer arrives:
- URL versioning —
/v1/users— most visible and easiest to reason about - Header versioning —
Accept: application/vnd.api+json;version=1— cleaner URLs, less discoverable
Whichever you choose, treat unknown fields as non-breaking on the client. A consumer that rejects unfamiliar fields cannot survive any additive change — which is why Jackson's default FAIL_ON_UNKNOWN_PROPERTIES causes so much trouble when consuming third-party APIs.
Security
Never put secrets in JSON that reaches a client. It is plain text; Base64 is not encryption.
Do not produce duplicate keys. {"role": "user", "role": "admin"} is undefined behaviour — parsers disagree on which wins, and two systems in one pipeline resolving it differently has produced real authorisation bypasses.
Validate depth and size on input. Deeply nested JSON can exhaust the stack of a recursive parser; a size cap and a depth cap are cheap defences.
Escape JSON embedded in HTML. The sequence </script> inside a string terminates the surrounding tag. Escape the forward slash as \/ — permitted precisely for this reason.
Practical checks
- Format files in the repository. Readable diffs are worth more than the bytes.
- Validate in CI so malformed JSON never merges. See JSON Validation.
- Publish a schema for anything crossing a boundary you do not control.
- Compress in transit rather than minifying. See Minifying JSON.
- Document your conventions — dates, nulls, naming, errors — in one place consumers can find.
Related
- JSON Syntax Rules — what is legal
- JSON Validation — enforcing these conventions
- JSON vs XML vs YAML — choosing a format
- JSON Formatter — format and validate