Home / Articles / Debugging Prisma Guard Failures: A Phase-Based Diagnostic Model

This article is published in English.

Debugging Prisma Guard Failures: A Phase-Based Diagnostic Model

Learn how to diagnose generated Prisma API failures by mapping errors to the exact phase—config, caller selection, validation, or response—that owns them.

2660 words

Begin troubleshooting by pinpointing which phase actually owns the failure.

Generated APIs can break down at multiple distinct points.

Scope note: the failure phases described here come from project documentation and pinned reproduction environments, not from broad usage statistics across a large user base.

A router can reject its own configuration before it ever serves a single request. A guard can reject a malformed shape the moment it's constructed. Caller routing can fail before a variant-specific hook even runs. Request validation can reject an individual body. A scoped operation can fail simply because the trusted context it depends on isn't present.

Beyond these, there's a trickier category: the request technically succeeds, yet the Prisma arguments that get generated, or the semantics of the response, diverge from what the application logic assumed.

Each of these categories demands a different fix and a different kind of test. Grepping the full error string is far less productive than asking two questions: when did this behavior first show up, and which layer is capable of observing it?

Start with a phase map

A generated Prisma request passes through several distinct boundaries on its way to execution:

router construction
  caller resolution
    operation before-hooks
      variant before-hooks
        guard shape construction
          request validation
            Prisma argument execution
              response transport

The precise sequencing of shape-related work can shift depending on whether a shape is static or depends on runtime context, but this diagnostic breakdown still holds as a useful mental model.

Failures at startup point to problems in route descriptors. Failures at the caller stage point to variant selection logic. Errors like Invalid query and Invalid data point to a mismatch between the request body and the declared shape. Policy failures point to missing trusted context. And when a request succeeds but the result is unexpected, you need to look past the status code entirely.

Keep in mind that error text is tied to specific versions. The guard-focused examples referenced here were produced against a fixed combination: prisma-guard at version 1.33.0, paired with Zod 4.4.3 and Prisma 6.19.3. The examples covering HTTP-based reads instead rely on a separate pinned set: prisma-generator-express 1.64.4 running on Node 22.14.0 against PostgreSQL 16.6.

Treat the exact wording of an error as evidence specific to that version combination. Treat the phase and underlying cause as the debugging model that will still be useful later.

Before a request: configuration cannot form a contract

Router construction is responsible for validating operation descriptors before anything else happens.

An operation is not allowed to configure both shape and variants at once. A variant map cannot be left empty. Every variant descriptor must include a shape. Reserved shape keys are not permitted to double as caller names.

These are deployment-time faults by nature. If the system caught them and kept running with a half-configured router anyway, it would quietly erase the boundary the application was supposed to enforce.

An operation that defines neither shape nor variants is a different situation entirely: it's technically valid, and it calls Prisma directly with no guard enforcement at all. Whether that's acceptable should be an explicit decision made during route review, not an accident.

Shape construction carries its own set of failure conditions. Empty combinators, empty projections, conflicting forced predicates, incomplete create shapes, malformed upsert structures, and bulk methods missing a where shape are all rejected up front, before any client-supplied data has a chance to interact with them unsafely.

A minimal reproduction is most useful when it keeps shape construction separate from the transport layer:

const query = guard.query('Plant', 'findMany', {
  where: {
    name: { contains: true },
  },
  take: { max: 50, default: 20 },
})
const args = query.parse({
  where: {
    name: { contains: 'fern' },
  },
})

This isolated path works well for exercising read filtering, ordering, pagination arguments, and most shape-construction errors. What it does not do is actually execute against Prisma, apply delegate-level read projection, or represent how mutations behave in practice.

Whatever the fix turns out to be, it belongs in server-side configuration. No amount of adjusting the request payload can repair a shape that's structurally unsound to begin with.

Before the handler: caller selection failed

When named shapes and variants are in play, there's an additional routing phase that runs before the request ever reaches the generated handler.

A caller is considered missing when the variant map has no default entry. A caller is considered unknown when nothing matches it: no exact key, no parameterized pattern, and no default. Two overlapping parameterized patterns don't get resolved by declaration order; the system treats the situation as ambiguous and fails instead.

Caller identification data is passed as a separate channel from the Prisma request body. Attempting to smuggle it inside the request arguments themselves gets rejected.

For public-facing contracts, using a header as an intentional caller selector can be a reasonable design choice. But for privileged variants, the selection should come from authenticated logic inside resolveVariant, not from client input. Giving a header a custom name doesn't make its value trustworthy.

Routing failures happen after operation-level before-hooks run, but before variant-specific hooks run. That ordering detail explains a subtle behavior: operation-wide authentication logic still executes even when no caller variant ends up matching, whereas caller-specific hooks never fire in that case.

The right fix isn't automatically "just add a default." A default caller silently accepts missing, blank, and unmatched caller values alike. Only add one if that fallback behavior is genuinely acceptable across all three of those scenarios.

During validation: the request exceeded its declared boundary

The read errors verified in this setup point to the exact argument path that triggered them.

An unrecognized field inside where means that field isn't part of the filter shape. An unrecognized field inside select means the request is trying to widen the projection beyond what's allowed. A rejected skip value means pagination skipping was never enabled for that shape. An error on take can mean either that the requested value exceeded its configured maximum, or that it arrived as the wrong scalar type entirely.

Generated GET helpers matter here because Prisma-shaped arguments don't all coerce the same way when they're built by hand from query strings. Numeric filter values and dates tend to coerce correctly in the positions where that's supported, but boolean values and pagination values passed as strings can fail to coerce properly. The safer choice is to use the generated encoder for GET requests, or fall back to native JSON via the POST-based read twin.

Write validation, by contrast, follows structure that's specific to each Prisma method. Create operations receive a data field. Update operations receive both where and data. Upsert operations receive where, create, and update. A guarded batch-create call expects its input to be an array.

Bulk operations can fail at two separate levels. If where is missing from the shape itself, that's a construction-time problem. If a runtime request body technically has a where but it resolves to no actual client-side condition, that's a request-time problem instead.

Policy errors form their own category again. A missing scope root, or missing context for a shape that depends on runtime context, both indicate that some piece of trusted state simply isn't present. Keeping the missing-scope behavior set to error mode is what prevents an absent context from silently turning into an unfiltered, top-level query.

The habit worth building here is preserving the exact path where something failed. Saying "got a 400 from the guard" tells you almost nothing useful. Saying "the read body attempted include.plants.take above its configured nested maximum" points directly at one specific node in the contract.

After the guard clears: a 200 status still hides real risk

A successful HTTP response only tells you that the route ran to completion. It says nothing about whether the value you sent was actually respected, whether a condition executed inside the branch you assumed it would, or whether the response used the projection you expected by default.

Take a fully forced top-level predicate: it overrides whatever the client sends without any visible sign of doing so. If a shape locks isPublished to true, a client submitting false still gets a success response, while the query that actually runs keeps the forced true value underneath.

Other forced fields behave the opposite way and reject client-supplied values outright rather than silently overriding them. Because forcing can behave inconsistently depending on where it's applied, your tests need to check who actually owns each argument rather than assuming one instance of force() generalizes to every field.

Forcing gets even trickier inside an OR clause. A forced condition placed there gets hoisted out and turned into a mandatory top-level constraint. So a shape that looks like it expresses "either the client's condition or the server's condition" can actually execute as the client's condition combined with the forced predicate using AND logic. If you genuinely need server-owned alternation, you need a dedicated query built for that purpose, or you need to enforce it at the database policy layer instead.

Response projection introduces its own quiet divergence. When a client omits a projection on a guarded read, the shape's default projection is applied, but that substitution happens at the point the delegate actually executes, not when guard.query().parse() runs.

Mutations don't follow the same rule. If enforceProjection isn't set, a client that leaves out a projection on a mutation gets no select clause injected at all, meaning Prisma's ordinary no-projection behavior takes over instead.

Nested scope enforcement is another spot where it's easy to assume more coverage than actually exists. Automatic scope only intercepts the top-level operations it explicitly supports. It does not reach into relations that get pulled in through a projection and filter them recursively. On top of that, the scope root itself is never filtered by its own marker, and any raw SQL you run completely bypasses the extension's enforcement layer.

None of these behaviors show up if you only check the status code.

Pick the correct read mechanism before trusting the response shape

The generated layer ships with three distinct mechanisms for delivering read results: paginated responses, POST-based transport, and Express-based server-sent events.

findManyPaginated returns a fixed outer shape:

type PaginatedResult<T> = {
  data: T[]
  total: number
  hasMore: boolean
}

The hasMore flag is trustworthy specifically for forward-offset pagination combined with a positive take value. If you use cursor-based pagination or a negative take, you can still get a boolean back, but it no longer carries that same guarantee. A take of 0 returns zero rows and a false continuation flag, while the total count remains intact.

The total count follows a different logic path entirely. Distinct counting respects a configured cap. A precomputed count source only gets used when the request is unfiltered, unguarded, and non-distinct. Any dynamic filter, distinct clause, or guard shape forces a fallback to a live count computed at request time.

That fallback preserves correctness, but it changes both the cost of the operation and where the number comes from. Treat the semantics of the total as a separate concern from the semantics of row slicing.

POST-based reads exist to handle payload size and encoding, not to expand what the query language can express:

POST /delivery/paginated
Content-Type: application/json
{"where":{"city":{"equals":"Bangkok"}},"take":20,"skip":0}

Send the body as native JSON. The GET and POST versions of the same route are expected to enforce an identical guard contract. If a hook rewrites the request body, that equivalence can break, because the GET path reads from already-parsed query parameters instead of a JSON body.

Server-sent events change when data arrives rather than what data arrives. This mechanism only makes sense if the client actually implements handling for progress events, a terminal success event, a terminal failure event, and a fallback path.

{"type":"progress","stage":"relations"}
{"type":"field","field":"summary","data":{"total":6}}
{"type":"result","data":{"summary":{"total":6},"deliveries":[]}}

Manually staged SSE events are application-level queries you write yourself, and they need explicit guard handling just like anything else. Auto-include only covers relation shapes that are documented and within the planner's limits; anything outside that falls back according to whatever fallback behavior is configured. Also, generated after-hooks are not a guaranteed mechanism for cleaning up an SSE stream.

Put together, a "successful" read can still be wrong for several independent reasons: an unreliable continuation flag, a misread of where the count came from, transport-specific hook behavior, or a staged query that was never guarded.

Point each test at the layer it can actually verify

No single end-to-end request can validate every layer at once.

Reach for the parser when you're testing body validation or forced-merge structure. Reach for the guarded delegate when the question is about execute-time projection or the final mutation arguments. Reach for the extension's operation path when you're testing whether automatic scope injection actually happened.

A standalone argument-capture harness lets you inspect the final mutation arguments without touching a database, but only if you wire the guard extension to a delegate that actually returns the arguments it received. Building a disconnected fake object proves nothing. This kind of harness tells you what arguments were emitted, not what rows a database would actually return.

For questions about tenant-level outcomes, relation ownership, transactional behavior, distinct totals, or provider-specific quirks, you need database-backed fixtures. Seed at least two tenants with rows that clearly diverge from each other so leakage is obvious if it happens.

For questions about generated routing, serialization, hook execution, GET/POST equivalence, pagination response shape, or SSE event sequencing, use HTTP-level tests.

Keep at least one guard-enabled contract test even if your browser end-to-end suite runs in a mode that disables guard validation. A browser test that passes under a relaxed mode proves nothing about what production will reject, because the enforcing layer was removed for the test.

Write each regression test at the lowest layer capable of proving the specific claim it's making. Narrower tests mean that when something breaks later, the failure points at the phase that owns it instead of forcing you to re-investigate the entire request path from scratch.

Work the failure in a single direction

A short, repeatable sequence keeps you from guessing at fixes:

  1. Figure out whether this is a startup failure, a request-time failure, or a successful response that surprised you.
  2. Identify which phase is responsible: router, caller resolution, shape, policy, Prisma execution, or transport.
  3. Cut the reproduction down to one operation, one shape, and one request body.
  4. Inspect the argument at whichever layer is closest to where the behavior originates.
  5. Only add database or HTTP execution to the test if the specific claim actually depends on it.
  6. Only compare exact error messages against the dependency version you have pinned.

Generated APIs become far easier to reason about once you keep their phases distinct from one another. Configuration mistakes should surface before any traffic is served. Requests that violate a rule should report exactly which part of the contract they violated. And a successful response should be checked against the arguments it actually emitted and the transport semantics that are documented for it, never against the status code alone.