The execution model
What happens between a POST to /graphql and the JSON that comes back: parse, validate, coerce variables, then walk the selection set, with sibling fields running together, top-level mutation fields running one at a time, and every claim read off a source-call log on both stacks.
Chapters 3 to 5 described the schema: its types, its wrappers and the contract that keeps two stacks aligned. This chapter follows a request through the engine that serves it. A GraphQL server does not run a query the way a database runs SQL. It parses text, checks it against the schema, converts its inputs, and then calls ordinary functions, one per field, in an order the specification mostly leaves to the engine. Knowing that order tells you which calls a selection costs, which of them wait for each other, and what a failed request leaves behind.
The code is in examples/06-execution-model/. It starts from the Chapter 3 slice and changes where the data comes from: five sources (booking, routing, voyage, location, delivery) stand in for the services that own it, each call takes 250 ms, and every call is written to a log that the example serves at /debug/source-calls. verify.sh reads that log after each query, so each claim below about order and overlap is observed on both stacks. The log is a lab tool and is off unless TRACKING_DEBUG_ENDPOINTS=true.
A request is a document plus inputs
Over HTTP a request is a JSON object with three members: query, the document text; variables, a JSON object of inputs; and operationName, which says which operation to run. The document can hold several operations and any number of fragments. The server runs exactly one operation, chosen by the GetOperation step of âExecuting Requestsâ in the GraphQL specification: the named one, or the only one when the document holds a single operation and no name is given. With several operations and no name, the specification says to raise a request error.
The example sends query A { cargo(trackingId: "ABC123") { trackingId } } query B { ... } with no operationName. SmallRye rejects it with Must provide operation name if query contains multiple operations., no data and no source call, as the specification describes. Strawberry 0.335.0 runs the first operation and answers ABC123, because its execution context takes the first operationâs name when the request has none. Both answer XYZ789 when operationName is B, and only booking.getCargo:XYZ789 appears in the log. The portable habit is to send operationName whenever a document holds more than one operation. It also gives each request a name in logs and traces.
A request error is the specificationâs term for a failure before execution starts. The current edition says ârequest errorâ and âexecution errorâ, where older editions said âfield errorâ for the second. The three stages below are where request errors come from.
Parse
Parsing turns the text into a document: a tree of operations, selection sets, fields, arguments and fragments. The only thing it checks is syntax. An unclosed brace, a missing name or a stray character stops the request, and nothing after parsing runs.
{ cargo(trackingId: "ABC123") { trackingId }
Quarkus answers Unparseable input document and omits data. Python answers Syntax Error: Expected Name, found <EOF>. with "data":null. The specification says data should not be present for a request error; Strawberry sends the key with a null value, so a client should test for absent or null. Both stacks answer HTTP 200 with Accept: application/json, and the log is empty on both, because no resolver was reached. A document that fails to parse also tells the server nothing about which fields it wanted, so there is nothing to execute partially.
Validate
Validation checks the parsed document against the schema without touching any data. The specification lists the rules in its âValidationâ section: every field exists on the type it is selected from, every object-typed field has a selection set and every leaf has none, arguments match their declared types, fragments are defined and used, fragment spreads do not form a cycle, and variables are declared, used and compatible with their positions. Chapter 3 showed three of these on the wire.
{ cargo(trackingId: "ABC123") { ...A } }
fragment A on Cargo { ...B }
fragment B on Cargo { ...A }
A fragment cycle would make execution unbounded, so validation rejects it. Quarkus reports Validation error (FragmentCycle@[A]) : Fragment cycles not allowed for each fragment in the cycle. Python reports Cannot spread fragment 'A' within itself via 'B'. An unknown field, cargo { weight }, fails the same way. All three leave the log empty. Validation needs only the document and the schema, so a client can fix the document and send it again with no risk of having changed anything. It also costs CPU on every request, which is why persisted queries (Chapter 15) and cost limits (Chapter 14) both start here.
Coerce variables
Variables arrive as JSON, and JSON has fewer types than GraphQL. Coercion converts each variable to the type its declaration names: a JSON string becomes a TrackingId through the scalarâs input coercion, a missing optional variable takes its default, and a JSON 123 offered to a string scalar is an error. The section is âCoercing Variable Valuesâ, and it runs before execution because the engine will not call a resolver with an argument it cannot trust.
query($t: TrackingId!) { cargo(trackingId: $t) { trackingId } }
With {"t":123}, Quarkus answers Variable 't' has an invalid value: SRGQL000013: Expected type [String or class extending class com.patterncatalyst.shipping.tracking.TrackingId] but was [BigDecimal]. and Python answers Variable '$t' has invalid value: Expected value of type 'TrackingId', but encountered error 'expected a string, got int'; found: 123. Both fail before any source call. The literal form of the same mistake, location(unLocode: 123), was rejected in validation in Chapter 3, so a literal fails in the second stage and a variable in the third. The difference matters when you read a failure: a variable error names the variable, and the value that caused it is in the request body, not in the document.
Execute: walking the selection set
Execution starts with the root selection set and the root value. The specificationâs algorithms, from âExecuting Operationsâ down to âExecuting Fieldsâ, reduce to a loop with three steps.
- Collect fields. âField Collectionâ groups the selection set by response key. Fields with the same key merge, fragments are expanded when their type condition applies, and
@skipand@includedrop the fields they exclude. Collection happens before any resolver runs, so a pruned field never costs a call. - Resolve each field. The resolver receives the parent value (the object the previous step produced), the fieldâs arguments and the request context, and returns the fieldâs value, or something that will become it.
- Complete the value. âValue Completionâ checks the result against the fieldâs type. A leaf is serialized, a list is completed item by item, an object is walked with the sub-selection as its selection set and the value as its parent, and a null in a non-null position is an error that Chapter 4 followed upward.
Figure 6.2 shows the walk for the query that the exampleâs second check sends: cargo { itinerary { legs { loadLocation { unLocode } } } delivery { transportStatus } }.
Resolver 1 is Query.cargo. It calls booking.getCargo and returns a Cargo, which becomes the parent for itinerary and delivery. Neither fieldâs resolver is passed the tracking id as an argument: each reads it from the parent. Resolver 2 returns an Itinerary whose legs list needs no call of its own, because the routing source returned the legs with the itinerary. Each Leg carries a voyage number and two UN/LOCODEs, and loadLocation turns the code into a Location. The first check in verify.sh shows the other side of the same rule: cargo { trackingId description } makes exactly one call, booking.getCargo:ABC123, because description is a property of the booking record and has no resolver of its own.
@Description("The planned route. Null until the routing service has assigned one.")
public Uni<Itinerary> itinerary(@Source Cargo cargo, Context context) {
return routing.getItinerary(context.getExecutionId(), cargo.trackingId().value());
}
@NonNull
@Description("Where the cargo is loaded.")
public Uni<Location> loadLocation(@Source Leg leg, Context context) {
return locations.getLocation(context.getExecutionId(), leg.loadUnLocode());
}
@strawberry.type
class Leg:
load_un_locode: strawberry.Private[str] # what routing returns; not in the schema
@strawberry.field(description="Where the cargo is loaded.")
async def load_location(self, info: Info[TrackingContext]) -> Location:
return await info.context.location.get_location(self.load_un_locode)
In SmallRye a @Source parameter is the parent value, and the method lives in the @GraphQLApi class beside the root fields. A Uni is Mutinyâs lazy single-value type: the method returns a description of the work, and the engine subscribes to it. The record components Leg uses for the codes are marked @Ignore, which keeps them out of the schema. In Strawberry the parent is self, strawberry.Private keeps a field out of the schema, and info.context is whatever the routerâs context_getter returned.
One request id across every call
The source-call log is only useful if it can say which request a call belongs to. Each stack gets that id from a different place, and each has to take it at the right moment.
On Quarkus the id is Context#getExecutionId(), the SmallRye Context passed to the resolver as a parameter. A resolver reads it at method entry, while it is still running for its request, and passes it to the source as an argument. The continuation of a delayed Uni runs later and on a different thread, where request-scoped state is not guaranteed to be the same requestâs. On Python the id is a uuid4 created once per request:
def get_context(calls: Annotated[SourceCalls, Depends(get_calls)]) -> TrackingContext:
request_id = str(uuid.uuid4())
return TrackingContext(request_id, BookingSource(calls, request_id), ...)
app.include_router(GraphQLRouter(schema, context_getter=get_context), prefix="/graphql")
Every resolver of the request sees the same info.context, so the five sources it holds are all bound to one id. Chapter 7 returns to the context and its other uses. The check assert_single_request confirms one id on every call of a query, and assert_distinct_requests confirms that two queries do not share one.
Sibling fields may run in parallel
Chapter 1 mentioned that sibling fields may run concurrently. The wording of âNormal and Serial Executionâ in the specification is that the executor can run the entries of a collected fields map âin whatever order it chooses (normally in parallel)â, because field resolution is expected to be free of side effects. That is a permission, not a promise. Whether two siblings overlap depends on the engine and on how the resolvers are written, so the example measures it.
The second check sends the Figure 6.2 query and asserts three things from the log: booking.getCargo ends before routing.getItinerary and delivery.getDelivery start, the two overlap, and the two getLocation calls overlap. Figure 6.3 draws one run on Quarkus.
The same query on the Python stack shows the same shape: after getCargo ends, getItinerary and getDelivery start together, and when they end, both getLocation calls start together. Total time follows the depth of the tree, three levels of 250 ms, and not the five calls. Doubling the number of siblings at one level would add nothing to the total, and adding a level would add 250 ms.
What makes siblings overlap is that the resolver gives control back while it waits. Both of the stacksâ idioms do that, and each has a way to lose it.
public <T> Uni<T> call(String request, String source, String op, String key, Supplier<T> lookup) {
return Uni.createFrom().deferred(() -> {
long startMs = nowMs();
return afterLatency(Uni.createFrom().item(lookup))
.eventually(() -> calls.add(new SourceCall(request, source, op, key, startMs, nowMs())));
});
}
async def call[T](self, request, source, op, key, lookup: Callable[[], T]) -> T:
start_ms = _now_ms()
try:
await asyncio.sleep(self._latency)
return lookup()
finally:
self._calls.append(SourceCall(request, source, op, key, start_ms, _now_ms()))
Uni.deferred delays the clock until the engine subscribes, delayIt().by(...) waits on a timer without holding a thread, and eventually writes the entry whether the call produced a value, a null or an error. The Python version does the same with await asyncio.sleep and a finally. The example wraps the delay in afterLatency, which applies the timer only when the configured latency is above zero. Both record startMs and endMs from a monotonic clock, so the checks compare them with < and <= and never subtract them.
Resolver style decides what a waiting call costs. These two observations are not part of verify.sh.
- Strawberry,
defresolvers that block. The same two resolvers written as plaindefwith a blocking sleep run one after the other, 250 ms each, on the event loop, and a blocking call also holds every other request on that loop for as long as it runs. graphql-core awaits coroutines together, and a function that never yields has no point at which to overlap. - SmallRye, resolvers that return a plain value. These are blocking methods, and SmallRye gives each call its own worker thread (
executor-thread-1and-2in the run). They overlap, with a worker thread held for the length of each call. AUniresolver is subscribed on a Vert.x event-loop thread and resumes there after the timer, so it holds no thread while it waits.
So the stacks differ in a way that matters when the pool is small: Quarkus runs a blocking resolver on a worker thread and spends a thread per call, and on Python a blocking resolver occupies the event loop. In both, a resolver that is asynchronous end to end is the one that scales with the number of concurrent requests instead of with the number of threads. Chapter 13 turns the next step, many sibling calls that want the same data, into batching.
Top-level mutation fields run serially
For a mutation the specification changes the rule. âNormal and Serial Executionâ says the fields of a mutationâs root selection set are executed serially, in the order written, and each field is executed completely, nested selections included, before the next begins. Nothing below that level changes: the fields inside one mutation fieldâs payload are siblings again and may run together.
mutation {
a: registerHandlingEvent(input: { ... }) { cargo { delivery { transportStatus } } }
b: registerHandlingEvent(input: { ... }) { cargo { delivery { transportStatus } } }
}
The aliases are needed because two fields with one name and different arguments would collide. The reason for the serial rule is that mutations have side effects, and b may depend on what a wrote. When a registers a load event and b registers the matching unload, b is only valid if a has landed, and aâs payload shows the state before b ran. Query fields get no such guarantee, which is why a client that needs ordering has to use a mutation. Figure 6.4 draws the rule.
graphql-java, the engine under SmallRye, has a serial execution strategy for mutations (AsyncSerialExecutionStrategy), and graphql-core runs the root fields of a mutation through execute_fields_serially. This chapterâs example has no mutation yet, so it does not assert any of this. Chapter 8 adds registerHandlingEvent and asserts the rule on both stacks with two independent observations: the order of appends in the source-call log, and the payload of a showing state that b has not touched.
Assembling the response
Execution ends with a map: the response keys, in order, each holding the completed value of its field. The specificationâs âSerialized Map Orderingâ says the response map should follow the order of the selection set, and a response key is the alias when the field has one and the field name otherwise.
{ b: cargo(trackingId: "XYZ789") { trackingId } a: cargo(trackingId: "ABC123") { trackingId } }
Both stacks answer {"data":{"b":{"trackingId":"XYZ789"},"a":{"trackingId":"ABC123"}}}, with b before a, although a sorts first alphabetically and either getCargo call might have finished first. The log shows the two calls overlapping, so completion order is not under the clientâs control. Selection order is, and that is the only order a client can rely on. verify.sh asserts the key order with json_keys, which reads keys in document order.
errors sits beside data. When a resolver fails, the specâs âHandling Execution Errorsâ has the engine record an error whose path names the failed field, null the field, and continue with its siblings, so partial data comes back with errors. Chapter 4 followed that null upward through non-null fields, and Chapter 9 turns the errors into a contract. A request error has no data at all, as the first three stages showed.
Meta fields cost nothing
Fields whose names begin with two underscores belong to the engine, not to a resolver. __typename returns the name of the object type the selection set is applied to, and __schema and __type answer from the schema itself. { __typename } returns {"data":{"__typename":"Query"}} on both stacks with an empty source-call log, which is the useful property of a meta field: it is answered after validation without a resolver, so it can sit in any selection set, including on an interface where Chapter 3 used it to find the runtime type, and it never adds a call. It is also a cheap way to check that a deployment parses, validates and executes a request end to end. Introspection is the same mechanism with a larger payload, and Chapter 17 covers when to turn it off.
Status codes
GraphQL-over-HTTP, the working draft in the Bibliography, takes up status codes in âStatus Codesâ. For an application/graphql-response+json response it makes one rule mandatory: a result without a data entry is a request error, and the server must answer with a 4xx or 5xx status. The codes it recommends are 400 when the document cannot be parsed, and 422 when validation fails, when the operation cannot be determined or when variables cannot be coerced. A result that executed is 200 without errors, and 294 (a code the draft defines for partial success) when errors sits beside data. A server must also support that media type. A client that accepts only application/json is a legacy client under the draft, and the draft asks servers to apply the same status codes to it, except that a 2xx response keeps the application/json media type. The point of the status code is that a gateway, a load balancer or a log can tell a rejected request from an executed one without reading the body.
The two stacks, as observed by verify.sh, answer as follows:
| Request | Quarkus | Python |
|---|---|---|
Parse error, Accept: application/json |
200, data absent |
200, "data":null |
Unknown field, Accept: application/json |
200 | 200 |
Unknown field, Accept: application/graphql-response+json |
200 with that content type | 200 with application/json |
Every request error in this chapter came back as HTTP 200: parse, validation and coercion on both stacks, and the missing operationName on Quarkus, the one stack that raised it. Quarkus acknowledges the new media type in the Content-Type header and answers 200 with no data; the draft recommends a 4xx for that case. Strawberry answers application/json, the media type the draft assigns to a legacy client. A client therefore reads the body to detect a request error on either stack: errors present and data absent or null. A gateway in front of the service that wants the 4xx semantics can add them. The observed values belong to the pinned versions, SmallRye GraphQL 2.18.5 and Strawberry 0.335.0, and verify.sh asserts them so that a change on upgrade is visible.
On the wire
Start the stack. Quarkus is on 8160 and Python on 8161, and compose sets TRACKING_SOURCE_LATENCY_MS=250 and TRACKING_DEBUG_ENDPOINTS=true. Reset the log, send a query that selects only a booking field, and read the log:
docker compose -f examples/06-execution-model/compose.yaml up -d --build
curl -s -X DELETE -w '%{http_code}\n' localhost:8160/debug/source-calls
curl -s localhost:8160/graphql -H 'Content-Type: application/json' -d '{"query":"{ cargo(trackingId: \"ABC123\") { trackingId description } }"}'
curl -s localhost:8160/debug/source-calls
204
{"data":{"cargo":{"trackingId":"ABC123","description":"Palletized electronics"}}}
{"calls":[{"request":"16323002360","source":"booking","op":"getCargo","key":"ABC123","startMs":98,"endMs":351}]}
One call, to the one source whose data the selection needs. Now the Figure 6.2 query, after another reset:
curl -s localhost:8160/graphql -H 'Content-Type: application/json' -d '{"query":"{ cargo(trackingId: \"ABC123\") { itinerary { legs { loadLocation { unLocode } } } delivery { transportStatus } } }"}'
curl -s localhost:8160/debug/source-calls
{"data":{"cargo":{"itinerary":{"legs":[{"loadLocation":{"unLocode":"USNYC"}},{"loadLocation":{"unLocode":"DEHAM"}}]},"delivery":{"transportStatus":"ONBOARD_CARRIER"}}}}
{"calls":[{"request":"16323002361","source":"booking","op":"getCargo","key":"ABC123","startMs":509,"endMs":760},{"request":"16323002361","source":"routing","op":"getItinerary","key":"ABC123","startMs":762,"endMs":1013},{"request":"16323002361","source":"delivery","op":"getDelivery","key":"ABC123","startMs":763,"endMs":1025},{"request":"16323002361","source":"location","op":"getLocation","key":"USNYC","startMs":1021,"endMs":1272},{"request":"16323002361","source":"location","op":"getLocation","key":"DEHAM","startMs":1023,"endMs":1274}]}
The request id, the startMs and endMs values differ on every run and on the Python stack, where the id is a uuid4. What stays the same is the shape: booking.getCargo ends before getItinerary and getDelivery start, those two overlap, and the two getLocation calls overlap. A failed request leaves the log empty. With the unclosed-brace document from the Parse section:
curl -s -X DELETE localhost:8161/debug/source-calls
curl -s -w ' [HTTP %{http_code}]\n' localhost:8161/graphql -H 'Content-Type: application/json' -d '{"query":"{ cargo(trackingId: \"ABC123\") { trackingId }"}'
curl -s localhost:8161/debug/source-calls
{"data":null,"errors":[{"message":"Syntax Error: Expected Name, found <EOF>.","locations":[{"line":1,"column":45}]}]} [HTTP 200]
{"calls":[]}
| Check | Expected |
|---|---|
cargo { trackingId description } |
Palletized electronics; the log is exactly booking.getCargo:ABC123; no routing.* or delivery.* calls |
cargo { itinerary { legs { loadLocation } } delivery } |
USNYC, DEHAM, ONBOARD_CARRIER; exactly the five calls above; getCargo before getItinerary and before getDelivery; getItinerary before getLocation:USNYC; getItinerary overlaps getDelivery; the two getLocation calls overlap; one request id |
{ b: cargo(XYZ789) ... a: cargo(ABC123) ... } |
data keys b,a; the two getCargo calls overlap; one request id; a later request has a different one |
parse error, unknown field weight, fragment cycle, {"t":123} for TrackingId! |
errors, no data, zero source calls; HTTP 200 on both stacks |
unknown field with Accept: application/graphql-response+json |
Quarkus 200 application/graphql-response+json; Python 200 application/json |
two operations, no operationName |
Quarkus: errors, no data, zero calls. Python: runs the first operation, one booking.getCargo:ABC123 call |
the same document, operationName B |
XYZ789; the log is exactly booking.getCargo:XYZ789 |
{ __typename } |
Query; zero calls |
__type of TrackingId, UnLocode, DateTime |
SCALAR; specifiedByURL null, https://unece.org/trade/uncefact/unlocode, https://www.rfc-editor.org/rfc/rfc3339 |
live schema vs contracts/schema/ |
no breaking change either way and no description change on what the stack serves (slice); the debug endpoints add nothing to it |
bash examples/06-execution-model/verify.sh runs all of it on both stacks and prints PASS only when both hold. It asserts order and overlap of 250 ms windows and never a duration, so a slow machine does not fail it.
Decision
The execution model is where the operational and security cost axis of Chapter 2 becomes concrete. A GraphQL endpoint runs whatever document a caller sends, and parse, validation and coercion are the three places where that work can be refused before any data is touched, which is also where depth limits, cost limits and persisted queries attach later. The aggregation across sources axis shows in the log: one query fans out to several owners, and the latency of the answer is the depth of the tree, so an asynchronous resolver style that lets siblings overlap is what makes aggregation affordable. The read and write balance axis appears in the serial rule, a cost that only mutations pay, and a reason to keep ordered, dependent writes in mutations rather than in chained queries. The contract and evolution style axis shows in the status codes: the draft recommends a 4xx for a request error and both stacks answer 200, so the contract with clients is the body of the response, and the tracking view treats errors and absent data as the signal and never the status line. The tracking view sends operationName on every multi-operation document, because one stack rejects an ambiguous request and the other runs the first operation.
Where this goes next
This chapter followed one request through parse, validate, coerce and execute, showed which calls a selection makes and which of them overlap, and recorded how each stack reports a request that never reached a resolver. The source-call log and the asynchronous sources stay in the example from here on.
Chapter 7 uses them to look inside a resolver: its parent, arguments, context and info, how a computed field such as Delivery is derived, how @include and @skip prune calls before they happen, and what it costs when two aliases fetch the same voyage twice. That repeated fetch is left visible until Chapter 13 batches it. Chapter 8 adds the first mutation and asserts the serial rule from this chapter on both stacks, and Chapter 9 gives execution errors a contract.
For the formal definitions, read âExecuting Requestsâ, âCoercing Variable Valuesâ, âExecuting Selection Setsâ with its âNormal and Serial Executionâ and âField Collectionâ subsections, and âHandling Execution Errorsâ in the GraphQL specification, and âStatus Codesâ in GraphQL over HTTP. Samer Bunaâs GraphQL in Action covers the same ground in âIntroduction to GraphQLâ and âImplementing schema resolversâ; the book is listed on the Bibliography page.