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:

Boolean Filter Coercion Failure Points Diagram showing a client sending is_active=false through three stages — HTTP serialization, server middleware parsing, and ORM query translation — with the two common failure points highlighted. Client is_active: false HTTP ⚠ string coerce Middleware ?is_active="false" truthy → true ✗ parse ⚠ full scan ORM / DB WHERE 1=1 all rows returned ✗ Result 200 ✓ wrong ✗ Fix 1: explicit string→bool mapping in middleware Fix 2: composite index on (is_active, created_at) Fix 3: align OpenAPI type: boolean with codegen strict mode

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.