89% of the public GraphQL web is one file
A survey of 1,000,000 domains.
89.23% of the public GraphQL endpoints in the Tranco top million serve the same schema: one file, byte-identical across all of them. It’s Shopify’s Storefront API. Add Magento and you’re at 97.67%. Everything else on the public web, every hand-built API and every other platform put together, comes to the remaining 2.33%.
That one number changes how to read all the others. Most published statistics about GraphQL on the web are computed over a population that is, in practice, one vendor’s default settings. So this post is in two parts: the survey, and then what happens to each of the usual numbers when you divide by vendor instead.
It helps to have a picture of the thing being counted before the counting starts. A GraphQL schema is a directed graph: types are the nodes, and a field on one type that returns another type is an edge. Here are four of them.
Figure 1. Four schemas as type graphs. Node = a type, edge = a field returning another type, and only types reachable from the root query are drawn. Shading is distance from the root. Node size and edge width are identical across panels, so the difference in density is a difference in type count and nothing else.
Top right is the file this post is about: 161 types, six levels deep, served by 31,778 sites. Beside it is the sort of thing somebody builds when nobody hands them a schema, 52 types and three levels. Bottom right is a single WordPress install at 820 types. The largest schema in the corpus, a self-hosted GitLab with 8,254 reachable types, isn’t drawn because it wouldn’t fit on a page next to these.
The survey
The scan covered 1,000,000 domains from the Tranco ranking and took about 120 hours. For each host it tried up to three conventional GraphQL paths and asked each one the same question: will you describe yourself?
| endpoint records reached | 645,796 |
| not GraphQL | 518,604 |
| answered but refused (403 / 401) | 66,635 |
| transport error | 22,870 |
| confirmed GraphQL | 38,192 |
| …with a schema captured | 35,604 |
| distinct schema contents | 3,310 |
Figure 2. Endpoint records by classification. The 518,604 that were plainly not GraphQL are omitted from the plot.
The biggest bar is the one nobody reports on. 66,635 endpoints answered and refused to be classified, which is 1.75× the entire confirmed GraphQL population. Some of those are GraphQL behind a WAF and some aren’t GraphQL at all, and from the outside, without credentials, there’s no way to tell which.
That much is measured. What follows is inference: a 403 from an edge proxy is itself a defensive posture, so refusal plausibly correlates with being better defended. If so, every public GraphQL security statistic, this one included, describes the less defended part of the population, and the gap is not small.
Nineteen products
98.51% of endpoints with a captured schema are running a schema that belongs to a recognisable product. That leaves 530 endpoints (1.49%) where a human wrote the schema for that particular site.
Figure 3. Endpoints by detected product. Log scale, because a linear one shows Shopify and nothing else.
| product | endpoints | share | distinct schemas |
|---|---|---|---|
| Shopify Storefront | 31,778 | 89.25% | 9 |
| Magento | 2,996 | 8.42% | 2,669 |
| no fingerprint | 530 | 1.49% | 391 |
| WPGraphQL | 109 | 0.31% | 101 |
| Node / Express | 65 | 0.18% | 42 |
| Apollo Federation | 34 | 0.10% | 21 |
| Craft CMS | 30 | 0.08% | 21 |
| Drupal | 22 | 0.06% | 21 |
| 11 others | 32 | 0.09% | 30 |
Magento’s row looks like diversity on paper (2,669 distinct schemas across 2,996 installs, nearly one per deployment), but it’s one product with different modules switched on. Hashing each schema by its sorted set of type names instead of its bytes, to see how much of that variety is cosmetic, only collapses 3,310 schemas to 3,026 shapes, a reduction of 8.6%. So the installs really do differ. They differ the way two WordPress sites with different plugins differ.
Magento is also the only product in the corpus that’s uniform enough to recognise from its content alone. If you cluster the schemas by shared field vocabulary at a 70% threshold, 81% of Magento’s land in a single cluster. Drupal’s 21 schemas fall into 19 separate clusters, and Apollo Federation’s 21 into 19. Magento generates its schema from a fixed core; the others assemble one per site, and two installs of the same product can end up with almost nothing in common.
None of that dents the concentration.
Figure 4. Cumulative share of endpoints against distinct schemas, ranked by deployment count.
43 schemas cover 90% of the public GraphQL web, or 28 if you go by type-name shape. Getting to 95% takes 1,530 schemas and getting to 99% takes 2,954, because once you’re past the shoulder of the curve the corpus is one-offs.
This isn’t 35,604 engineering decisions. It’s about twenty, and one of them is 89%.
What that does to every statistic
Here is the same corpus, with four common statistics computed over everything and then by vendor.
Figure 5. Four statistics, computed corpus-wide and then by vendor.
| corpus-wide | Shopify | Magento | everything else | |
|---|---|---|---|---|
| introspection enabled | 93.23% | 100.00% | 76.57% | 33.27% |
| fields marked non-null | 20.6% | 64.1% | 18.7% | 30.3% |
| fields deprecated | 23.67% | 2.48% | 27.60% | 2.27% |
| snake_case field names | 82.0% | 6.4% | 93.3% | 6.1% |
“94% of GraphQL endpoints expose introspection” is probably the most-quoted number in this field. Shopify’s Storefront API is documented as publicly introspectable, so that’s a product decision, and it sets the aggregate on its own. Strip out the two platforms and introspection is enabled on 33% of what’s left, which is the figure for APIs whose operators actually chose the setting.
Stratifying the other three rows flips them.
- Deprecation. 23.67% of the corpus’s fields are marked deprecated and still queryable, which looks like a web-wide pile-up of dead surface. Nearly all of it comes from Magento’s code generator. Outside Magento, the median schema deprecates 0.05% of its fields.
- Nullability. 79% of the corpus’s fields may return null, which would oblige every client to check every response. But Shopify, the largest single deployment, marks 64.1% of its fields non-null. There isn’t a web-wide level here, just a spread between vendors that runs from about two-thirds guaranteed down to about one-fifth.
- Naming. The corpus is 82% snake_case, which goes against the ecosystem convention. Outside Magento it’s about 94% camelCase, in line with it.
When there’s a monoculture in the denominator, an unstratified statistic is a statement about the monoculture. That’s the whole finding, really, and it applies to the measure people ask about most.
99.5% of endpoints have a cycle in their type graph that’s reachable from the root query and passes through a list-returning field, which is the structural precondition for query amplification. Of the schemas that have one, 0.57% declare a cost, depth or rate directive. Both numbers are real, and neither says much. The structure is close to definitional (a category contains categories, a product relates to products), and it reads 99.5% because 89% of the corpus is one file that happens to have a cycle. Stratified, it’s 100% at Shopify, 100% at Magento, and 80.6% everywhere else.
The dominant schema doesn’t move, until it moves everywhere at once
Over fifteen days of rescans, 1,046 endpoints came back with a schema different from the one on record. Whether that number means anything depends on two things.
The first is whether they’re independent events or one vendor push fanned out across thousands of hosts. The largest single schema-to-schema transition in those fifteen days covers 16 endpoints, so within this window they’re independent. That stops being true later, and the section ends with the case where it does. The second is the denominator: on a 90-day rescan cadence only part of the corpus was revisited, so churn has to be measured against what was actually looked at.
Figure 6. Share of rescanned endpoints whose schema changed substantively over fifteen days. Description-only edits, and schemas that generate a fresh default value on every request, are excluded.
| rescanned | changed | churn | at 8 days | |
|---|---|---|---|---|
| Shopify Storefront | 12,694 | 1 | 0.01% | 0.01% |
| Magento | 3,017 | 493 | 16.34% | 10.50% |
| no fingerprint | 528 | 177 | 33.52% | 25.57% |
| WPGraphQL | 109 | 83 | 76.15% | 68.81% |
| Apollo Federation | 32 | 23 | 71.88% | 40.63% |
| Node / Express | 63 | 31 | 49.21% | 46.77% |
That’s a seven-thousand-fold range, and the last column is what makes it credible. Between the eight-day and fifteen-day measurements, every population except Shopify roughly doubled, which is what you’d expect if churn is accumulating over time. Shopify sat at exactly one changed endpoint the whole time, over more endpoints and twice the window. The instrument moves. The monoculture does not.
That one changed endpoint out of 12,694 has types in its schema that the Storefront API doesn’t define, so it’s probably not Shopify at all, just misfingerprinted as it. Meanwhile a WordPress site running WPGraphQL had roughly a three-in-four chance of its public API surface moving inside two weeks.
So the shape of the web’s GraphQL is set by the vendors who revise it least. The 11% that churns is invisible in any aggregate, and the 89% that doesn’t is what every aggregate ends up measuring.
Then, a week later, it moved.
On 21–22 August the Storefront API changed on 1,455 endpoints in two days. Measured the naive way (endpoints changed over endpoints rescanned), Shopify’s churn went from 0.01% to 11.24% overnight, a thousandfold jump. That number is wrong in exactly the way this section has been warning about.
Every one of the 1,455 is the same transition: same old hash, same new hash, byte-identical diff. It’s one event, seen once per host the rescan happened to reach. Counted as events rather than endpoints, Shopify’s churn over the window is 1, which is what it was before.
The diff itself is small and breaking: four fields removed, among them QueryRoot.shopPayInstallmentsPricing, plus one enum value. These are removals, so any client that was using them breaks. And because the change ships to every store at once, the corpus’s dominant schema hash dropped from 89.2% to 85.2% in 48 hours, while a new hash appeared at 4.0% and is still climbing as the rescan reaches the rest.
This is what a monoculture looks like from the outside. For the twenty-two days from the first rescan to that deploy, the single largest object on the public GraphQL web didn’t change at all, and then one push changed it on every host at once. There’s no intermediate state to observe, no gradual adoption, no version skew. One file, and then a different file.
Two things would have inflated these numbers if I hadn’t filtered them out. Drupal produced 3,335 description edits against 39 substantive ones, so 99% of its schema movement is prose rather than structure. And five endpoints report a change on every single rescan, because they embed a freshly generated UUID as an argument’s default value and are therefore never the same twice.
Most change is also a one-off, which suggests the public GraphQL web is redeployed occasionally rather than developed continuously. Over the full twenty-two days, of the endpoints that moved at all, 2,201 changed once, 195 twice, and 127 three times, and most of that last group is the Shopify deploy landing on hosts that had already been rescanned once.
As for what actually moves when something moves: there were 122,220 substantive changes across the twenty-two days, three quarters of them Magento upgrades, and the single largest was one Magento endpoint whose schema changed in 9,400 places at once, 4,129 of them breaking. Operations that existed and now don’t include Mutation.Ticketing_scanBarcode, Mutation.InsuranceLogin, Query.MemberAddress, and an entire feature-flag CRUD surface. Arriving in the same window were Mutation.managePayoutAccount, Mutation.manageJoyCredit, and Mutation.revalidateSubscriptionsValidatedBetween.
The 1.5%
Everything so far describes inherited schemas. The population I find most interesting is the 530 endpoints (391 distinct schemas) that match no fingerprint, because those are the ones where somebody actually made decisions.
Figure 7. Hand-built schemas against platform schemas.
| bespoke | platform | |
|---|---|---|
| median fields | 862 | 2,656 |
| median SDL | 57 KB | 356 KB |
| median mutations | 31 | 68 |
| read-only | 16.6% | 1.5% |
| subscriptions | 16.9% | 2.0% |
Upload scalar |
31.2% | 1.2% |
Relay node(id:) fetcher |
22.0% | 3.9% |
Hand-built schemas are about six times smaller, eleven times more likely to be read-only, and eight times more likely to use subscriptions.
Almost all of the security-shaped surface in the corpus also lives in this 1.5%.
File upload. The Upload scalar appears in 31.2% of bespoke schemas and in zero Shopify or Magento schemas. 33 of them pair it with an admin-prefixed operation.
The Relay global object fetcher. A root node/nodes field returns any object in the graph from an opaque ID. If authorization is enforced per resolver rather than per object, this is the field that routes around it, and 22% of bespoke schemas expose one.
Money movement. 54 schemas expose mutations with names like refundOrder, withdrawMoney, transferamounttobankaccount and capturePayPalPayment. That’s 9.7% of bespoke schemas, 0.3% of Magento, and zero at Shopify. The platforms keep money operations off the public schema, and people rolling their own tend to put them on it.
Federation internals. 15 schemas expose _entities or _service on the root query. That’s subgraph machinery, there so a gateway can resolve references, and it belongs behind the gateway. 14 of the 15 are non-platform. It’s the one finding in the corpus that is an unambiguous misconfiguration rather than a vendor default.
GraphiQL. 286 endpoints serve a live interactive IDE. By product that’s Node/Express at 29.6%, PostGraphile at 50%, and Strapi at 33%, against Magento at 0.23% and Shopify at zero.
None of that tells you whether any of it is reachable; a mutation in a schema means the surface exists, not that you can call it, and introspection only ever publishes the map, never the locks.
One correction to that population: 56 of the 391 bespoke schemas share the root type name Domain and a vocabulary of _type, fileName, mainLocation and isHistory. That’s a single digital-asset-management product with no fingerprint yet, so it’s 56 installs of one thing rather than 56 hand-built APIs, and the real monoculture is slightly worse than measured.
What is actually in there
Schema size mostly tells you how much code generated the schema, and not much about what the API does. The median public schema is 340 KB. The largest is 3.3 MB, a self-hosted GitLab. The most types in one schema is 9,337, which is a different document: a hand-built Node service. If you divide SDL bytes by callable operations you get a rough measure of how much type machinery a client downloads per thing it can actually do: 10,832 bytes for Drupal, 8,919 for Shopify, 8,617 for WPGraphQL, and 555 for that Node service, a twentyfold spread.
The generator’s fingerprints show up in the type names. The longest in the corpus is 129 characters, from a genetics database:
AlleleSplitSystemCombinationsBySplitSystemCombinationComponentAlleleAlleleIdAndSplitSystemCombinationIdManyToManyConnection
This one is shorter, and worse:
AboutLiveEventsWidgetImagesdesktop_background_180x163_1440
That’s pixel dimensions and a CSS breakpoint inside a GraphQL type name, on the public internet, which means a redesign is also an API change.
You can often fingerprint a backend’s implementation language from a scalar name alone, without sending a single extra request. Shopify’s public schemas emit NaiveDate, NaiveDateTime and NaiveTime, which are chrono (Rust) and Elixir type names leaking through a serializer that had no opinion about them. I found nine backends identifiable this way from SDL alone: @cypher and @relation mean Neo4j, ObjectId means MongoDB, JwtToken means PostGraphile, GraphQLStringOrFloat means Directus, and lowercase bigint/float8/daterange next to @cached means Hasura. One isn’t even a type: a description reading “Soft-deletes a Contact (paranoia deleted_at)” names the Ruby paranoia gem, so that one’s Rails.
Scalars are where people confess. The corpus has a scalar named DangerouslyNonSpecificScalar, one named TODO, and the pair UnTypedObject and UnTypedObject2, where the 2 tells you the first escape hatch wasn’t enough. There’s BooleanOrString, IntFalse, and one just called Odd. And there are three tiers of HTML cleaning, SanitizedHtml, SecureSanitizedHtml and StrippedHtml, which makes you wonder what the first one was for.
If you filter the corpus down to text that appears in three or fewer distinct schemas, what’s left is roughly what a human typed. The dominant genre is the zombie field: still queryable, hardcoded to lie.
This doesn't do anything anymore, but is kept to avoid breaking existing queries
always return nil (not in use, and it requires heavy db queries)
AMP removed in SM-2567; always false.
The second is my favourite, a field that does nothing, expensively. The third shipped an internal ticket number to the internet.
Deprecation reasons are where developers are most honest, since they’re explaining a mistake: Is it required???, Not a real field, should not be there, This is an outdated deprecation warning. One field named deprecated is deprecated with the reason “Fields are not being deprecated”. Elsewhere crux_round_trip_time is deprecated with "Invalid Name. Please use cruxRoundTripTIme", a notice that complains about an invalid name and then points at a name that is also wrong. Both spellings are now load-bearing.
A recurring shape is the hostage situation, where a field can’t die because of something outside the team’s control: retained only for SmartTV, Do not use. Kept for backwards compatibility (iOS legacy app), and keeping this field around until we can update the the contractor app, typo intact. (The contractor app, you sense, is worse than the legacy iOS app in every respect.) The champion is German and still live in 2026:
Nicht mehr Benutzen! Die Linkliste wird mit dem Relaunch 2020 gelöscht.“Do not use! The link list will be deleted with the 2020 relaunch.”
That’s six years past its own execution date. Its opposite number elsewhere in the corpus is a field deprecated with termination planned for 2028-03, which schedules the removal two years out.
And one field’s entire public description is:
derrived from console.log('rateLimit', rateLimit.rateLimitDirectiveTypeDefs);
That isn’t a comment about debugging. It’s the console.log line itself, typo and all, serving as public API documentation. Someone was debugging their rate-limit directive setup and this is the only trace of it that reached production, in a corpus where 99% of schemas declare no rate limit at all.
A few more that I collected along the way. There’s a mutation called absorbUser, which is the best verb in the corpus. There’s UNSAFE__dangerouslyDeleteAsset, which warns you twice in one identifier, and _noop, a field that does nothing and was shipped on purpose. One API documents its own error condition with a hedge and a typo: Probably because the card was decliend. And the long tail of the web is stranger than the front page. I found a whitewater rafting API with oddly contemplative documentation about rapids, a metallurgy API with AlloyTemperItem, loot-box probability as a first-class concept (createLootboxItemProbabilityGroup, buyAndOpenLootbox), a devotional app (generatePrayerAudio, lentPrayer), maskLicensePlates, AirportGhostReplay, and a March-Madness-style bracket named activeKinkMadnessEvent, complete with adminAdvanceKinkMadnessDay.
86.6% of field names in the corpus appear in exactly one schema (361,967 of 417,823), and the top 100 names cover only 9.8% of all field definitions. So there’s a small shared core of id, name, description and title sitting on top of a vast private vocabulary. 155 schemas are more than half deprecated. 46 have over 500 fields and not a single description among them.
Scope
A reachable amplifying cycle is only a precondition; it isn’t a vulnerability in itself. A declared directive isn’t an enforced control, and the absence of one isn’t the absence of a control either, since a server can limit depth in resolver code where no schema will show it. An operation in a schema says nothing about whether it can be invoked. I tested none of this: I didn’t authenticate to any endpoint, call any mutation, or probe any vulnerability.
Type graphs come from the reference GraphQL parser, and I reproduced every structural result with a second, independent implementation before quoting it. The scan tried three candidate paths per host and did no subdomain enumeration. That was a deliberate trade: it gives up coverage to get a defined denominator, and you can’t report a rate without a population. Tranco is a popularity ranking, so all of this generalises to popular domains rather than to the web. And because the survey scans in rank order, rank and scan time are the same variable, which means no result stratified by popularity is interpretable until a later pass shuffles the frontier.
All of these numbers are a snapshot. The scanner runs on a 90-day rescan cadence, and the live database had already moved past them by the time I finished writing.
How it was collected
The scan was read-only throughout. It fetched and honoured robots.txt before any probe, including Crawl-delay. It rate-limited itself to 2 requests per second per destination netblock, and separately capped itself at 60 newly-contacted netblocks per minute, which bounds how fast it reaches new parties rather than just how hard it hits known ones. Each host got roughly four requests, once, with no return visit for 90 days.
The scanner identifies itself in its User-Agent with an opt-out URL, its address resolves to forward-confirmed reverse DNS, and that URL, graphcensus.org/scanner, serves a page explaining what the traffic is and how to stop it. The survey’s own page is graphcensus.org.