Debugging Projection Field Validation Errors
This page is part of the Sparse Fieldsets & Projection reference under Query Patterns & Data Shaping Strategies. It is a debugging guide for the errors that surface when a client sends a fields (or ?fields[user]=) parameter naming something your endpoint will not project — unknown fields, forbidden internal columns, illegal nested paths, and empty projections — and how to answer each with the correct status code and a machine-readable body.
The Symptom
Sparse fieldsets let a client ask for ?fields=id,name,email and receive only those keys. The trouble starts when the requested set does not match what the resource actually exposes. You will see one of these signatures in logs or a failing client: a 500 because the projection was pushed into a SELECT that named a non-existent column, an internal field like password_hash leaking into a response because projection bypassed the serializer’s redaction, a 200 with an unexpectedly full object because a bad fields value was silently ignored, or a nested path such as owner.account.secret reaching into a relation the contract never promised. Each is a validation gap. The remedy is to classify every requested path before it touches the query and reject the bad ones deterministically.
This is the projection counterpart to handling invalid sort field errors: the same allowlist discipline applies, but projection adds two wrinkles — forbidden internal fields and nested paths.
Diagnostic Table
Classify the request first. Whether a rejected path is 400 or 403 depends on whether the field is unknown or known-but-forbidden.
| Symptom | Example input | Classification | Status |
|---|---|---|---|
| Unknown field | ?fields=id,nickname |
Not in the resource vocabulary | 400 |
| Forbidden internal field | ?fields=id,password_hash |
Known field, not projectable | 403 |
| Illegal nested path | ?fields=owner.account.secret |
Depth or relation not allowed | 400 |
| Empty projection | ?fields= |
Malformed — empty value | 400 |
| Duplicate / malformed token | ?fields=id,,id |
Cannot be parsed | 400 |
The rule of thumb: 400 means “this path is not a thing I can project,” and 403 means “this path is a real field, but you are not allowed to select it.” For fields a client should never even learn exist, collapse the 403 case into 400 so the response does not confirm the column is real.
The OpenAPI Contract
Mark projectable fields directly on the schema with a vendor extension so the allowlist and the response shape share one source of truth:
# openapi/components/schemas/User.yaml (OpenAPI 3.1.0)
User:
type: object
properties:
id: { type: string, x-projectable: true }
name: { type: string, x-projectable: true }
email: { type: string, x-projectable: true }
password_hash: { type: string, x-projectable: false, writeOnly: true }
owner:
x-projectable: true
$ref: '#/components/schemas/OwnerRef'
The x-projectable flags are the machine-readable allowlist. A build step reads them to emit the allowed-path set the handler validates against, and a linter fails the build if any property lacks the flag — that is what keeps the allowlist in sync with the schema.
Validation Flow
Step-by-Step Resolution
Step 1 — Parse and normalize the paths
Split on commas, trim, drop empties as errors, and reject duplicates. An empty fields= value is a 400, distinct from omitting the parameter entirely (which means “return the default representation”).
// TypeScript — parse fields into normalized paths
const PATH = /^[a-z_]+(\.[a-z_]+){0,1}$/; // allow one level of nesting
export function parseFields(raw: string): string[] {
if (raw.trim() === "") throw new ProjectionSyntaxError("empty"); // -> 400
const seen = new Set<string>();
return raw.split(",").map((p) => {
const path = p.trim();
if (!PATH.test(path) || seen.has(path)) {
throw new ProjectionSyntaxError(path); // -> 400
}
seen.add(path);
return path;
});
}
Step 2 — Classify against the allowlist and forbidden set
Split known-but-forbidden paths from genuinely unknown ones so you can return 403 and 400 respectively.
// TypeScript — derived from x-projectable flags at build time
const PROJECTABLE = new Set(["id", "name", "email", "owner", "owner.id"]);
const FORBIDDEN = new Set(["password_hash", "internal_notes"]);
export function classify(paths: string[]) {
const unknown: string[] = [];
const forbidden: string[] = [];
for (const p of paths) {
if (FORBIDDEN.has(p) || FORBIDDEN.has(p.split(".")[0])) forbidden.push(p);
else if (!PROJECTABLE.has(p)) unknown.push(p);
}
if (unknown.length) throw new UnknownFieldError(unknown); // -> 400
if (forbidden.length) throw new ForbiddenFieldError(forbidden); // -> 403
return paths;
}
# Python — classify projection paths
PROJECTABLE = {"id", "name", "email", "owner", "owner.id"}
FORBIDDEN = {"password_hash", "internal_notes"}
def classify(paths: list[str]) -> list[str]:
unknown, forbidden = [], []
for p in paths:
root = p.split(".")[0]
if p in FORBIDDEN or root in FORBIDDEN:
forbidden.append(p)
elif p not in PROJECTABLE:
unknown.append(p)
if unknown:
raise UnknownFieldError(unknown) # -> 400
if forbidden:
raise ForbiddenFieldError(forbidden) # -> 403
return paths
Step 3 — Return a Problem+JSON body
Use application/problem+json and an errors array so a client can programmatically see which paths failed and why. The type URI differs by error class.
# Python (FastAPI) — 400 for unknown projection fields
from fastapi import Request
from fastapi.responses import JSONResponse
async def unknown_field_handler(request: Request, exc: "UnknownFieldError"):
return JSONResponse(
status_code=400,
media_type="application/problem+json",
content={
"type": "https://api-contract.com/problems/unknown-projection-field",
"title": "Unknown projection field",
"status": 400,
"detail": (
f"Cannot project {', '.join(exc.fields)}. "
"Projectable fields: id, name, email, owner."
),
"instance": str(request.url),
"errors": [
{"path": f, "reason": "unknown_field"} for f in exc.fields
],
},
)
A forbidden field returns the same body shape with status 403, type .../problems/forbidden-projection-field, and reason "forbidden_field". Reusing one structure means the client parses a single Problem+JSON error contract across both cases.
{
"type": "https://api-contract.com/problems/forbidden-projection-field",
"title": "Forbidden projection field",
"status": 403,
"detail": "Field password_hash cannot be projected.",
"instance": "/v1/users/42?fields=id,password_hash",
"errors": [
{ "path": "password_hash", "reason": "forbidden_field" }
]
}
Keeping the Allowlist in Sync With the Schema
The most common regression is a new schema property that nobody added to PROJECTABLE, so a legitimate field returns a spurious 400. Prevent it by generating the allowlist from the schema rather than hand-maintaining it. The pattern mirrors adding sparse fieldset support to your OpenAPI specs: every property carries x-projectable, and a CI check fails if any property is missing the flag.
# Build step — derive the projectable set from the OpenAPI schema
import yaml
def build_allowlist(schema: dict) -> tuple[set, set]:
projectable, forbidden = set(), set()
for name, prop in schema["properties"].items():
flag = prop.get("x-projectable")
if flag is True:
projectable.add(name)
elif flag is False:
forbidden.add(name)
else:
raise SystemExit(f"Property '{name}' missing x-projectable flag")
return projectable, forbidden
Because the allowlist is generated, adding a field to the schema without deciding whether it is projectable breaks the build instead of silently producing wrong 400s or leaking data.
RFC and Standard Alignment
| Concern | Standard | Rule |
|---|---|---|
| Unknown / malformed path | RFC 9110 §15.5.1 | 400 Bad Request — path is not projectable |
| Known but not permitted | RFC 9110 §15.5.4 | 403 Forbidden — field exists, projection denied |
| Error body format | RFC 9457 | application/problem+json with core members |
| Extension members | RFC 9457 §3.2 | errors array is a valid extension |
| Not confirming hidden fields | OWASP API3:2023 | Collapse 403 into 400 for fields clients must not discover |
RFC 9457 supersedes RFC 7807 but preserves the media type and member names, so the existing Problem+JSON implementation covers both projection error classes.
Caching and Safety Implications
Projection changes the response body, so the fields parameter must be part of the cache key — either directly or via Vary. A cached ?fields=id,name response must never be served for ?fields=id,email. Validation failures (400/403) carry Cache-Control: no-store so a corrected request is not shadowed by a cached error. Projection itself keeps the GET safe and idempotent, but it must fail closed: an invalid fields value can never fall back to returning the full object, because that both hides the client bug and risks exposing fields the projection was meant to strip.
SDK and Codegen Downstream Effect
When projectable fields are enumerated in the spec, a generator can expose them as a typed set and even narrow the response type to the requested subset:
// Generated client — projectable fields as a literal union
type UserField = "id" | "name" | "email" | "owner";
function getUser<F extends UserField>(
id: string,
fields: F[],
): Promise<Pick<User, F>>;
// getUser("42", ["id", "name"]); // Promise<Pick<User,"id"|"name">>
// getUser("42", ["password_hash"]); // compile error — not a UserField
The forbidden password_hash never appears in UserField, so the SDK cannot even request it. Generators that model fields as a raw string[] push the entire class of errors to runtime 400/403 responses, which is exactly what publishing x-projectable in the schema lets you avoid.
Common Mistakes
| Mistake | Correct approach |
|---|---|
Pushing the raw fields value into SELECT |
Validate against a generated allowlist first |
Returning the full object when fields is invalid |
Fail closed with 400/403 and Problem+JSON |
Same 400 for unknown and forbidden fields |
Use 400 for unknown, 403 for known-but-denied |
| Hand-maintaining the projectable set | Generate it from x-projectable with a CI sync check |
Omitting fields from the cache key |
Add fields to the cache key or Vary header |
FAQ
Should a request for a forbidden field like password_hash return 400 or 403?
Return 403 Forbidden when the field is a real, documented field that the caller is not permitted to project — an internal audit column, a write-only credential, or a field gated behind a scope. Return 400 Bad Request when the field simply does not exist in the resource vocabulary. The distinction only helps when the field is known to exist. For sensitive fields a client should never learn about, deliberately collapse the 403 into a 400 so the response does not confirm the column is real and cannot be used to probe your schema.
How should an empty fields parameter be handled?
Treat fields= with an empty value as malformed and return 400, because an empty projection is almost always a client serialization bug rather than a genuine request for zero fields. This is different from omitting the parameter entirely: no fields parameter means “return the default representation.” Never silently return the full object for an empty fields value, since that masks the client bug and can surface fields the client did not intend to fetch.
How do I keep the projection allowlist in sync with the schema?
Derive the allowlist from the same source of truth as the response schema instead of maintaining a separate list. Tag each schema property with a vendor extension such as x-projectable: true|false, generate the projectable and forbidden sets from those flags at build time, and add a CI check that fails if any property lacks the flag. New fields then cannot drift out of sync: adding a property without classifying it breaks the build rather than silently producing spurious 400s or leaking data.
Related
- Sparse Fieldsets & Projection — up-link: the section covering projection design and
fieldsparameter contracts - Query Patterns & Data Shaping Strategies — up-link: the parent reference on filtering, sorting, pagination, and projection
- Adding Sparse Fieldset Support to OpenAPI Specs — the sibling that defines the
x-projectableschema contract this guide validates against - Handling Invalid Sort Field Errors — the same allowlist discipline applied to sort parameters
- REST vs GraphQL Pagination Contract Tradeoffs — how field-selection contracts differ between REST projection and GraphQL selection sets