Handling Complex Boolean Filtering in REST APIs
This page is part of the Advanced Filtering Operators reference, which sits within the broader Query Patterns & Data Shaping Strategies guide.
Boolean query parameters look deceptively simple — a flag is either true or false. In practice they produce two distinct classes of production bugs: framework-level string coercion that silently inverts filter intent, and URL length violations when AND/OR/NOT logic is encoded in query strings. This page gives you the spec contract, the server-side fix, the database index strategy, and the CI gate to prevent regressions.
When This Problem Appears
You will hit boolean filter bugs in any of these situations:
- A
GET /users?is_active=falsecall returns all users, including active ones, because the router treated the string"false"as truthy. - An OpenAPI-generated TypeScript client serializes
is_active: falseas the string"false"rather than the literalfalse, causing the server’s strict parser to reject the request with400 Bad Request. - A complex filter like
is_active=true&is_admin=falsetriggers a 414 URI Too Long on a reverse proxy with a low request-line limit. - A cached response for
?is_active=trueis served to a request for?is_active=1because the cache key treats the two representations as distinct URLs.
Spec Snippet
Declare boolean filter parameters with type: boolean in OpenAPI 3.1. This is the source of truth that client generators, validators, and middleware parsers should all derive from.
Incorrect — causes coercion failures:
parameters:
- name: is_active
in: query
schema:
type: string # wrong primitive type
example: "true" # generator will emit a string, not a boolean
Correct — strict boolean typing:
openapi: "3.1.0"
info:
title: Users API
version: "1.0.0"
paths:
/users:
get:
summary: List users
parameters:
- name: is_active
in: query
required: false
schema:
type: boolean
description: >
Filter to active (true) or inactive (false) users.
Omit to return both.
- name: is_verified
in: query
required: false
schema:
type: boolean
description: Filter to email-verified users only.
- name: is_admin
in: query
required: false
schema:
type: boolean
responses:
"200":
description: Filtered user list
"400":
description: Invalid boolean parameter value
For nested AND/OR/NOT logic that cannot be expressed as flat boolean flags, model a POST /users/search endpoint that accepts a structured JSON filter body — shown in step 4 below.
Step-by-Step Resolution
Step 1 — Reproduce the coercion bug
# Capture the raw QUERY_STRING at the ingress layer
curl -v "https://api.example.com/v1/users?is_active=false&is_verified=true" 2>&1 \
| grep -E "< (HTTP|x-raw-query)"
# Confirm the server's interpretation by checking the response count
curl -s "https://api.example.com/v1/users?is_active=false" | jq '.total'
# If total equals the full user count, the filter was silently ignored
Enable structured request logging in your framework to capture the parsed parameter map, not just the raw URL. Compare QUERY_STRING (raw) with the map the router hands to your handler.
Step 2 — Add explicit string-to-boolean mapping in middleware
Node.js / Express:
import { Request, Response, NextFunction } from "express";
const BOOLEAN_PARAMS = ["is_active", "is_verified", "is_admin"] as const;
export function parseBooleanQueryParams(
req: Request,
_res: Response,
next: NextFunction
): void {
for (const key of BOOLEAN_PARAMS) {
const raw = req.query[key];
if (raw === undefined) continue;
if (raw === "true") {
req.query[key] = true as unknown as string;
} else if (raw === "false") {
req.query[key] = false as unknown as string;
} else {
// Reject unknown representations — never silently default
_res.status(400).json({
type: "/errors/invalid-query-param",
title: "Invalid boolean value",
detail: `Parameter '${key}' must be 'true' or 'false', got '${raw}'`,
status: 400,
});
return;
}
}
next();
}
Python / FastAPI:
from fastapi import FastAPI, Query, HTTPException
from typing import Optional
app = FastAPI()
@app.get("/users")
async def list_users(
is_active: Optional[bool] = Query(default=None),
is_verified: Optional[bool] = Query(default=None),
is_admin: Optional[bool] = Query(default=None),
):
# FastAPI automatically coerces 'true'/'false' strings to bool
# and rejects anything else with 422 Unprocessable Entity
filters = {
k: v for k, v in {
"is_active": is_active,
"is_verified": is_verified,
"is_admin": is_admin,
}.items() if v is not None
}
return await user_service.list(filters=filters)
FastAPI’s Query type annotation handles coercion natively. For frameworks that do not (Express, Gin, Spring without @RequestParam(defaultValue)), the middleware in the TypeScript example above is the correct pattern.
Step 3 — Add composite database indexes
Boolean columns have low cardinality — the query planner will rarely use a standalone boolean index because the estimated row count is too high relative to a sequential scan. Pair each boolean flag with a high-selectivity column:
-- PostgreSQL: composite index for active-user time-range queries
CREATE INDEX idx_users_active_created
ON users (is_active, created_at DESC)
WHERE is_active = true; -- partial index narrows the index further
-- Verify the planner uses the index
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, email FROM users
WHERE is_active = false
ORDER BY created_at DESC
LIMIT 25;
Run EXPLAIN ANALYZE in staging before and after adding the index. If the planner still performs a sequential scan, check for parameter-sniffing issues and run ANALYZE users to refresh column statistics.
Step 4 — Use a POST body for nested AND/OR/NOT logic
When filter complexity exceeds flat boolean flags — for example, (is_active AND NOT is_admin) OR (is_verified AND created_at > 2024-01-01) — encode the logic in a JSON body rather than a query string:
// POST /users/search
// Content-Type: application/json
interface BooleanFilterNode {
op: "AND" | "OR" | "NOT";
children: Array<BooleanFilterNode | BooleanLeaf>;
}
interface BooleanLeaf {
field: "is_active" | "is_verified" | "is_admin";
value: boolean;
}
// Example request body
const searchBody: BooleanFilterNode = {
op: "OR",
children: [
{
op: "AND",
children: [
{ field: "is_active", value: true },
{ op: "NOT", children: [{ field: "is_admin", value: true }] },
],
},
{
op: "AND",
children: [
{ field: "is_verified", value: true },
],
},
],
};
const response = await fetch("/v1/users/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(searchBody),
});
import httpx
search_body = {
"op": "OR",
"children": [
{
"op": "AND",
"children": [
{"field": "is_active", "value": True},
{"op": "NOT", "children": [{"field": "is_admin", "value": True}]},
],
},
{"op": "AND", "children": [{"field": "is_verified", "value": True}]},
],
}
async with httpx.AsyncClient() as client:
response = await client.post("/v1/users/search", json=search_body)
RFC Compliance Callout
RFC 9112, Section 3.2 (which obsoletes RFC 7230) recommends that servers support a minimum request-line length of 8000 bytes. Complex boolean chains in query strings can exceed this limit, producing 414 URI Too Long. The POST /search pattern avoids this entirely.
RFC 3986, Section 3.4 defines query strings as sequences of pchar characters with no specified parsing semantics. The string "false" is a valid pchar sequence — the standard places no obligation on servers to interpret it as a boolean. Your spec and middleware define that contract, not the protocol.
HTTP semantics (RFC 9110, Section 9.3.1) classify GET as safe and cacheable. POST is neither by default. If you move boolean filter logic to POST /search, you lose automatic HTTP caching — see the caching section below.
Idempotency, Safety, and Caching Implications
GET requests with boolean query parameters are safe and idempotent by definition. CDNs and shared caches can key on the full URL, including the query string. This creates a subtle cache-fragmentation risk: ?is_active=true, ?is_active=1, and ?is_active=True are three distinct cache keys but may represent the same filter intent. Canonicalize boolean values to true/false in lowercase at the middleware layer before they reach the cache layer, and ensure your statelessness and caching strategies normalize query parameters before computing cache keys.
POST /search endpoints are not cacheable by default. If read performance matters, add Cache-Control: max-age=N to responses and use a Surrogate-Key or Vary header strategy to allow selective purging. Alternatively, keep simple boolean flags in GET query parameters (where caching works naturally) and reserve POST for genuinely complex filter trees.
SDK / Codegen Downstream Effect
When the OpenAPI spec declares type: string instead of type: boolean, generated clients emit strings:
// BEFORE: spec declares type: string — generator emits:
export interface GetUsersParams {
is_active?: string; // "true" / "false" — coercion bug at runtime
is_verified?: string;
}
// AFTER: spec declares type: boolean — generator emits:
export interface GetUsersParams {
is_active?: boolean; // native boolean; serializer handles correctly
is_verified?: boolean;
}
# BEFORE
params = {"is_active": "false"} # string — truthy in many parsers
# AFTER (pydantic model aligned with spec)
class FilterParams(BaseModel):
is_active: Optional[bool] = None
is_verified: Optional[bool] = None
params = FilterParams(is_active=False, is_verified=True)
response = httpx.get("/v1/users", params=params.model_dump(exclude_none=True))
# httpx serializes False → "false" correctly
Run openapi-generator with --additional-properties=strictSpec=true (or the equivalent for your generator) so that any type: string on a parameter that should be type: boolean is flagged at generation time rather than discovered at runtime.
Common Mistakes
| Mistake | Correct Approach |
|---|---|
OpenAPI spec declares type: string for boolean flags |
Use type: boolean; align middleware, ORM, and generated clients to the same primitive type |
Framework default truthy check treats "false" as true |
Add explicit "true" / "false" string mapping in middleware; reject anything else with 400 |
| Standalone index on boolean column | Create composite index pairing boolean with a high-selectivity column; use a partial index where selectivity is extreme |
| Complex AND/OR/NOT logic in GET query string | Move to POST /search with a structured JSON filter body to avoid URL length limits and ambiguous encoding |
| Cache key includes raw boolean string without normalization | Canonicalize boolean values to lowercase true/false before caching; avoid 1/0/yes/no variants |
FAQ
Why does my REST API treat ‘false’ as true in boolean filters?
Most query parsers evaluate non-empty strings as truthy. The string "false" is non-empty, so it passes a truthiness check. Implement explicit string-to-boolean coercion in middleware or use strict JSON-serialized query parameters so that "false" unambiguously maps to the boolean false primitive.
How do I enforce boolean consistency across auto-generated clients?
Define type: boolean in your OpenAPI 3.1 spec, enable strict mode in your code generator (openapi-generator, openapi-typescript), and add CI contract tests that validate serialized query strings match the published spec before every merge.
Should complex boolean logic use query strings or POST bodies?
Simple boolean flags belong in query strings. For nested AND/OR/NOT logic, switch to a POST /search endpoint with a structured JSON body to avoid URL length limits (RFC 9112 recommends servers support at least 8000 bytes for request lines), parsing ambiguity, and cache fragmentation.
Related
- Advanced Filtering Operators — parent reference covering operator syntax, type coercion, and filter contract validation
- Query Patterns & Data Shaping Strategies — pillar covering pagination, sorting, projection, and filtering contracts
- Offset vs Cursor Pagination — pagination strategy choice that interacts with boolean filter performance
- Sparse Fieldsets & Projection — controlling response shape alongside boolean filtering
- RFC 7807 Problem+JSON Implementation — structured error format for
400 Bad Requestresponses on invalid boolean parameters