Adding Required Request Fields Safely
This page belongs to Breaking Change Detection in the API Versioning & Deprecation reference. Making a request field required is the most common intentional break in a maturing API — a field that “should always have been there” now needs enforcing. It is also the one with the cleanest staged migration, provided you start before the enforcement rather than at it.
The decision trigger
| Situation | What it means |
|---|---|
| A field is present in 98% of requests and the code assumes it | you want to enforce, and can stage it |
| Downstream systems need a value the API never collected | you need a default, or a new operation |
| A validation rule exists in code but not in the schema | the spec understates the requirement — fix the spec first |
| A required field is needed for regulatory reasons on a date | enforcement date is fixed; stage backwards from it |
| No sensible default exists for existing callers | this cannot be staged — version it |
Spec snippet: the three states
# Stage 1 — optional, with a documented server-side default
components:
schemas:
OrderCreate:
type: object
required: [items] # `channel` deliberately absent
properties:
items: { type: array, items: { $ref: "#/components/schemas/OrderItem" } }
channel:
type: string
enum: [web, mobile, partner, internal]
description: >
Origination channel. Optional until 2026-11-30, after which it is
REQUIRED. Omitting it currently defaults to `web` and returns a
deprecation warning in the response.
# Stage 3 — required, after the announced date
components:
schemas:
OrderCreate:
type: object
required: [items, channel] # enforced
properties:
items: { type: array, items: { $ref: "#/components/schemas/OrderItem" } }
channel:
type: string
enum: [web, mobile, partner, internal]
description: Origination channel. Required since 2026-11-30.
The description in stage 1 carries the whole plan: what the default is, when it stops applying, and that a warning is being emitted. A reader of the specification learns everything they need without finding a changelog entry.
Step-by-step
Step 1 — Ship it optional, with the default in one place
// create-order.ts — the default and the deadline live together
const CHANNEL_DEFAULT = "web";
const CHANNEL_REQUIRED_FROM = Date.parse("2026-11-30T00:00:00Z");
export async function createOrder(req: Request, res: Response) {
const suppliedChannel = req.body.channel as string | undefined;
const enforcing = Date.now() >= CHANNEL_REQUIRED_FROM;
if (!suppliedChannel && enforcing) {
return res.status(422).type("application/problem+json").json({
type: "urn:api:errors:v1:validation-failed",
title: "Request validation failed",
status: 422,
violations: [{
pointer: "/channel",
code: "required",
message: "channel became required on 2026-11-30.",
}],
});
}
const channel = suppliedChannel ?? CHANNEL_DEFAULT;
const order = await orders.create({ ...req.body, channel });
const body: Record<string, unknown> = { data: order };
if (!suppliedChannel) {
body.warnings = [{
code: "channel_will_become_required",
message: `channel was defaulted to "${CHANNEL_DEFAULT}". It becomes required on 2026-11-30.`,
}];
res.setHeader("Deprecation", "true");
res.setHeader("Sunset", "Mon, 30 Nov 2026 00:00:00 GMT");
}
res.status(201).json(body);
}
Emitting Sunset on the request pattern rather than the endpoint is a slight stretch of RFC 8594 — the header describes a resource’s retirement — but it puts the date somewhere a generic client already looks. Pair it with the in-band warning so the signal reaches both machine and human readers, following the same in-band principle as Sunset & Deprecation Headers.
Step 2 — Measure adoption per caller
-- Who still omits the field, and when did they last do it?
SELECT api_key_id,
count(*) FILTER (WHERE channel_supplied) AS with_channel,
count(*) FILTER (WHERE NOT channel_supplied) AS without_channel,
max(occurred_at) FILTER (WHERE NOT channel_supplied) AS last_omission
FROM order_creation_log
WHERE occurred_at > now() - interval '14 days'
GROUP BY api_key_id
HAVING count(*) FILTER (WHERE NOT channel_supplied) > 0
ORDER BY without_channel DESC;
This query is the difference between “we announced it, so it is their problem” and “four integrations are affected, and here is who to email”. It also tells you when adoption has plateaued, which is the real signal that the remaining callers will never move without enforcement.
Step 3 — Decide the date from the data, not a policy
Step 4 — Enforce, and let the diff record it
At the enforcement date the schema changes and the spec diff will correctly report a breaking change. That is the point: it should be recorded, approved and released as a major SDK version rather than slipping through.
# .oasdiff-ignore.yaml — the approved, dated break
- id: request-property-became-required
path: /orders
method: POST
reason: >
`channel` became required on 2026-11-30 as announced 2026-08-15. Adoption
reached 99.4% and the four remaining callers were contacted individually
(ticket API-3104).
approved_by: orders-team
expires: 2027-01-31
Each stage of the migration produces a different diff classification, which is how the gate stays honest throughout:
Standard compliance
| Element | Standard | Clause | Note |
|---|---|---|---|
required keyword |
JSON Schema Validation 2020-12 | §6.5.3 | Applies to the whole instance |
default annotation |
JSON Schema Validation 2020-12 | §9.2 | Annotation only — never applied by a validator |
422 for a missing field |
RFC 9110 | §15.5.21 | The body parsed but broke a rule |
Deprecation / Sunset |
RFC 8594 | §2, §3 | Announcing the enforcement date in-band |
| Warning conveyance | — | — | No current standard header; use the body |
The default row is the one implementers get wrong. A default in JSON Schema is documentation, not behaviour: no validator fills it in. The server must apply the default explicitly in code, and the schema annotation exists so consumers and generated clients know what will happen when they omit the field.
SDK / codegen downstream effect
// Stage 1 — generated client, field optional
export interface OrderCreate {
items: OrderItem[];
- channel?: "web" | "mobile" | "partner" | "internal";
+ }
+ // Stage 3 — generated client after enforcement (major version)
+ export interface OrderCreate {
+ items: OrderItem[];
+ channel: "web" | "mobile" | "partner" | "internal";
}
At stage 1 the SDK compiles for callers who omit the field, so upgrading is safe and they can adopt at leisure. At stage 3 the property becomes non-optional and their code stops compiling — which is exactly the desired behaviour for a major version, because a compile error at upgrade time is far better than a 422 in production. Publishing that major release follows Publishing Generated SDKs from GitHub Actions.
Common mistakes
| Mistake | Correct approach |
|---|---|
| Marking the field required in the same release it appears | Ship optional with a default first |
Relying on JSON Schema default to populate the value |
Apply the default in server code; the annotation is documentation |
| Announcing only in a changelog | Warn in-band on every affected response |
| Picking the enforcement date from a policy | Derive it from measured adoption |
| Enforcing while adoption is still climbing | Wait for the plateau, then contact the remainder |
| Inventing a default that is wrong for some callers | If no honest default exists, version instead |
| Enforcing without updating the schema | Spec and behaviour must change together |
FAQ
Can a default value make a new required field non-breaking?
It makes the transition non-breaking, not the requirement. While the server substitutes a default, the field is optional in practice: existing clients keep working unchanged, and the spec diff correctly classifies the addition as additive. What a default cannot do is make the eventual enforcement free — the day the server starts rejecting omissions, every client that never adopted the field breaks. Think of the default as buying a migration window rather than avoiding the migration, and put the enforcement date in the field description from day one so nobody mistakes the window for permanence.
What if no safe default exists?
Then the field genuinely cannot be added to the existing operation, and pretending otherwise is the worst option available. A default that is wrong for some callers converts a loud, immediate failure into silent bad data that surfaces weeks later in reconciliation. The honest choices are a new operation or resource variant that requires the field from the start, leaving the old one deprecated, or a new API version. Both are more work than a default, and both are far less work than correcting three months of mis-attributed records.
How long should the transition window be?
Long enough for your slowest consumer to notice, plan, implement and release — which you should measure rather than guess. For internal services deployed weekly, two to four weeks is often enough. For third-party integrations, quarterly release cycles and change-control processes make three to six months realistic. For embedded or on-premises clients, a year is not unusual. The reliable signal is the adoption curve: when it plateaus, remaining callers are not moving on their own, and the right response is direct contact plus a firm date — not another month of waiting.
Related
- Breaking Change Detection — up-link: the classification this change is measured against
- API Versioning & Deprecation — up-link: the parent reference for versioning and retirement
- Detecting Breaking Changes with oasdiff — the gate that records the approved break
- Validation Error Payloads — the error contract used once enforcement begins
- Exposing Pagination Metadata Without Breaking Clients — the response-side counterpart to this staged evolution