Designing API Boolean Fields: Two-State, Three-State, and the Nullable Bugs That Hide in Defaults
A nullable boolean has three states: true, false, and null. APIs that document the field as a boolean and treat null as false at some layers but as unknown at others produce bugs customers hit months after integration.
A boolean field looks like the simplest data type an API can expose. The customer sets it to true or false, the platform stores it, the platform returns it. There is no cardinality, no encoding, no time-zone problem. The complexity that does exist comes from the question of what to do with absence.
A field that is documented as a boolean and stored as a nullable column in a relational database actually has three states: true, false, and null. The question is whether the API exposes those three states consistently, collapses them to two states with a documented default, or treats them inconsistently across different code paths.
The two-state field
The cleanest API surface for a boolean is two states with a documented default. The field is required on creation. The field cannot be null. If the customer does not specify a value, the platform writes the default and the field becomes true or false from then on. The customer never sees null and never has to think about null.
This works well for fields that have a single sensible default. A webhook subscription field "active" should default to true. A user field "email_verified" should default to false. A flag field "killswitch_enabled" should default to false. The default is part of the product semantics, not a polite fiction the platform maintains to hide a complication.
The implementation discipline is to write the default at the application layer on creation, store the column as NOT NULL, and reject null in update payloads with a clear validation error. The schema and the API contract agree. The customer cannot accidentally produce a null state by omitting the field.
The three-state field
Some fields legitimately have three states. A user feedback field "would_recommend" has true, false, and not-answered. A subscription field "auto_renew" has true, false, and not-yet-decided. A product field "in_stock" has true, false, and unknown-because-we-have-not-checked.
The API design choice is between exposing the three states as a nullable boolean and exposing them as an enum. The enum is almost always better. An enum with explicit values "true", "false", "unknown" forces the customer to handle all three cases in their code rather than discovering the third state when their boolean check produces unexpected behavior.
A nullable boolean in JSON serializes to true, false, or null. Many client libraries default to treating null as false in conditional checks. The customer reads the documentation, sees "boolean", and writes "if (response.field) { ... }". The code works for true. It silently treats false and null identically. When the field changes from false to null on some records due to a schema migration or a backend code path that does not set it, the customer's logic does not change behavior, which means the customer does not notice the bug.
The default-vs-null asymmetry
The pattern that produces the most production bugs is a field documented as a boolean with a default, where the default applies to creation but not to updates. The customer creates a record without specifying the field. The platform writes true (the default). The customer later updates the record and accidentally omits the field. The platform either writes null (overwriting the previous true) or leaves the field unchanged.
The PATCH semantics here are critical. JSON Merge Patch (RFC 7396) specifies that fields absent from the patch are left unchanged. JSON Patch (RFC 6902) specifies that operations are explicit. Most B2B SaaS APIs that accept "partial updates" use Merge Patch semantics even when they do not document the choice explicitly.
With Merge Patch semantics, an omitted field is unchanged. The customer who sends {"name": "new"} updates only the name. The boolean field keeps its current value. This is usually what the customer expects.
With PUT semantics, an omitted field is reset to the default. The customer who sends {"name": "new"} resets every other field. This is rarely what the customer expects and is the source of much customer confusion when the platform documentation says "PUT" but the implementation is closer to PATCH semantics.
The null-as-explicit-clearing pattern
One useful convention is to treat null in an update payload as an explicit request to clear the field. The customer sends {"field": null} to mean "set this back to the default" or "remove this value". This works only for fields where null is a valid stored state (i.e., the three-state case) or where the default is well-defined.
The implementation discipline is to distinguish between a key being absent from the payload (leave unchanged) and a key being present with value null (clear). Many JSON deserializers collapse these two cases, producing application code that cannot tell the difference. The mitigation is to deserialize into a structure that preserves the distinction, which most production languages support but with some friction.
The cost of supporting this convention is that the API must document it clearly. Customers who do not know about null-as-clearing will be surprised when their {"field": null} payload changes the field. Customers who do know about it will use it for fields where it makes sense and avoid it elsewhere.
The validation discipline
A defensible policy for boolean fields in a B2B SaaS API is: required on creation with no default unless the default is part of the product semantics; non-null on storage; rejected if null in update payloads; documented behavior for absence in PATCH updates; and an enum used wherever a third state legitimately exists.
This policy produces fewer customer bugs than the alternative of nullable booleans with implicit defaults and inconsistent treatment of null across code paths. The cost is some upfront discipline in API design and some occasional pushback from customers who would prefer to leave fields unspecified.
The bugs the policy prevents are the ones that show up months after integration, in customer code paths that have not been tested with the full state space, surfacing as silent behavior changes when the platform makes an internal change that affects which rows have which state. Those bugs are expensive to debug and erode customer trust in proportion to how confusing the resolution is.
Read more essays and technical writing at anethoth.com — a notebook on databases, distributed systems, biology, and the engineering that holds the world together.