Best Practices
The habits that make WhisperGraph queries return in milliseconds, and the four traps that look like habits and are not.
On this page (9)
Best Practices Documentation
A slow or empty WhisperGraph query almost always traces to the same handful of mistakes: an unanchored scan over a billion-node label, a traversal with no LIMIT, a synthesized edge buried in a variable-length pattern, or an edge walked in the wrong direction. None are subtle once you know them. The difference between an instant answer and a query that never comes back is almost always whether you anchored and bounded it.
The graph holds roughly 7.4 billion nodes and 39 billion edges, so the engine relies on you to start narrow. Anchor on an indexed name, bound every fan-out, and let the pre-joined structure do the work. For the full model see the Graph Schema; for ready-made recipes, the Use Cases.
Read
coveragebeforeband. Only known-clean — coverage: known-clean. In coverage, no malicious evidence. licenses the word "clean"; no-data — coverage: no-data. Not in coverage. This is not a verdict — nothing was looked at. means unknown, which is a different thing again; malicious-evidenced — coverage: malicious-evidenced. In coverage, with positive evidence of malice. and ambiguous — coverage: ambiguous. In coverage, and the evidence points both ways. mean there is evidence, whatever the band says.whisper.explaindoes not returncoverageat all. Full contract: Coverage — what we looked at.
The performance habits
- Anchor every query with
{name: "value"}on billion-node labels.HOSTNAMEandIPV4are too large to scan. An anchored lookup is an indexed, instant operation that bounds every downstream hop. Inline{name: "..."}andWHERE h.name = "..."are planned identically, as aNodeLookup; prefer the inline form for readability. Confirm withEXPLAIN— aNodeLookupleaf means the anchor is indexed. Unanchored scans on these labels do not finish. - Bound high-fan-out intermediates with
WITH ... LIMITbefore expanding. Anchor, narrow to a handful of nodes, then traverse outward. A trailingLIMITcaps the output, but the engine still expands every intermediate row first, so cut a wide middle hop down mid-pattern. - Write synthesized edges as explicit single hops.
ROUTES,ANNOUNCED_BY,LISTED_IN,BELONGS_TO,PEERS_WITH, andCONFLICTS_WITHare computed at query time. Variable-length expansion over them is expensive, not broken: write them as explicit single hops joined withWITH— not because[*..]fails, but because it will not finish. - Anchor synthesized edges on the physical side.
LISTED_INexpands from an IP or hostname out to its feeds; walking it the other way — from a feed to every listed node — is not the fast direction. - Use
STARTS WITHand selective, leading-dotENDS WITHover regex. Both are index-backed on.name. AvoidCONTAINSonASN.name— it scans. - Use
OPTIONAL MATCHfor sparse WHOIS fields. Many domains have partial or redacted registration data; a plainMATCHdrops the whole row when one piece is missing. - Always add a
LIMIT, including onCALL ... YIELD ... RETURN. Usecount()to size a result before pulling it, and never run a global edge count — use the stats endpoint instead. - Use
UNWINDfor batch lookups. Each element stays an anchored lookup, so a batch of a few hundred indicators is fast. - Pick the right IP-to-prefix edge.
BELONGS_TOgives the allocated (RIR) prefix;ANNOUNCED_BYgives the BGP-announced prefix. UseANNOUNCED_BYwhen you intend to walk on to the routing ASN. - Remember mail and nameserver edges point server → domain. A domain's mail servers are
(domain)<-[:MAIL_FOR]-(mx). - Anchor
LINKS_TOtraversals. The hyperlink graph is the largest edge set; an unanchored walk through it is expensive. - Reach for the procedures first.
explain(),whisper.origins(),whisper.variants(), andwhisper.history()answer the hardest questions in one call — usually faster and cleaner than a hand-written deep traversal.
Anchor, then bound
MATCH (ip:IPV4 {name: "104.21.112.1"})<-[:RESOLVES_TO]-(sib:HOSTNAME)
WITH sib LIMIT 200
MATCH (sib)-[:LISTED_IN]->(f:FEED_SOURCE)
RETURN sib.name, collect(f.name) AS feeds LIMIT 50
The WITH sib LIMIT 200 caps the co-tenant set before the second hop fans out again. Without it, the second hop runs against the entire unbounded co-tenant set first. Use count() when you don't know how wide a node fans out — check cardinality before you pull the rows.
Never count edges from an unanchored pattern
A whole-graph aggregate like MATCH ()-[r]->() RETURN count(r) is refused: the engine answers HTTP 400 with reason: "global_edge_count" and tells you to read the precomputed figure instead (measured 2026-08-09). Totals come from the stats endpoint, and per-type counts from db.relationshipTypes().
curl -s -A "whisper-client/1.0" https://graph.whisper.security/api/query/stats
The refusal is the good case. Anchor one endpoint by type and the same count comes back as a silent, confident 0. MATCH ()-[r:LISTED_IN]->() RETURN count(r) answers 0 at HTTP 200 in milliseconds, while db.relationshipTypes() reports 10,931,701 LISTED_IN edges. ANNOUNCED_BY and CONFLICTS_WITH behave the same way. Those three are synthesized at query time and a global count never expands them — read the count from CALL db.relationshipTypes() YIELD type, count, never from count(r).
Do this, not that
| Do this | Not that |
|---|---|
MATCH (h:HOSTNAME {name: "x.com"}) | MATCH (h:HOSTNAME) WHERE h.name CONTAINS "x" |
Anchor, then WITH ... LIMIT, then expand | One deep all-in-one pattern |
ENDS WITH ".cloudflare.com" (selective) | ENDS WITH "cloudflare.com" (broad scan) |
count(p) for a magnitude question | count(DISTINCT p) when you just need order of magnitude |
GET /api/query/stats for totals | MATCH ()-[r]->() RETURN count(r) |
OPTIONAL MATCH for WHOIS/geo | MATCH that silently drops sparse rows |
(domain)<-[:MAIL_FOR]-(mx) | (domain)-[:MAIL_FOR]->(mx) (wrong direction) |
(ip)<-[:RESOLVES_TO]-(h) for reverse DNS | (ip)-[:RESOLVES_TO]->(h) (forward-only edge) |
[:CHILD_OF*1..3] (bounded) | [:CHILD_OF*] (unbounded) |
CALL explain("AS13335") | Scan ASN → PREFIX → IP → LISTED_IN (does not finish) |
CALL whisper.variants("brand.com") | Manual STARTS WITH lookalike sweeps |
Mind the edge directions
Walking an edge the wrong way returns zero rows with no error — the single most common cause of a "correct-looking" query that comes back empty. The directions that trip people up:
| Edge | Stored direction | To go the other way |
|---|---|---|
RESOLVES_TO | HOSTNAME → IP (forward only) | reverse DNS: (ip)<-[:RESOLVES_TO]-(h) |
NAMESERVER_FOR | server → domain | a domain's nameservers: (d)<-[:NAMESERVER_FOR]-(ns) |
MAIL_FOR | server → domain | a domain's MX: (d)<-[:MAIL_FOR]-(mx) |
CHILD_OF | child → parent | a parent's children: (parent)<-[:CHILD_OF]-(child) |
LOCATED_IN | IP → CITY (then HAS_COUNTRY) | chain it: (ip)-[:LOCATED_IN]->(:CITY)-[:HAS_COUNTRY]->(:COUNTRY) |
PEERS_WITH | ASN ↔ ASN (symmetric) | matches either arrow |
Don't hand-roll what a procedure already does
Several investigations look like a tempting multi-hop scan but are far faster, and safer, as a CALL. Procedures wrap the expensive logic server-side, so they come back where an unanchored walk does not.
| Instead of hand-rolling… | Call this |
|---|---|
Walking ASN → PREFIX → IP → LISTED_IN to score a network | CALL explain("AS13335") — scored verdict + factors + sources |
STARTS WITH / regex sweeps for lookalike domains | CALL whisper.variants("brand.com") |
| Reconstructing WHOIS or BGP timelines by hand | CALL whisper.history("indicator") |
| Chasing real IPs behind a CDN | CALL whisper.origins("domain.com") |
explain() in particular replaces the most dangerous pattern there is — a scan down a large ASN's prefixes to find threat listings, which reliably never comes back. Let the procedure do it. Full signatures are in the Procedures reference.
A clean or NONE verdict from explain() means "not listed at this granularity," not "safe" — no data is not the same as benign. Read the coverage before you treat an indicator as clean.
What zero rows means
A refusal is an error, not an empty result. If WhisperGraph will not run a query, it says so: you get an HTTP 4xx with a reason in the body. You will never silently get zero rows because a query was refused.
So zero rows means one of two things: your labels or edge names are wrong, or Whisper genuinely has no observation. Check
CALL db.labels()andCALL db.relationshipTypes()first — both are cheap and both answer immediately. If the query is right, the absence is real, and an absence is not a clean verdict.
The one exception is the global edge count above: count(r) over LISTED_IN, ANNOUNCED_BY or CONFLICTS_WITH returns 0 at HTTP 200. That is not zero, and it is not a refusal — read the count from db.relationshipTypes() or GET /api/query/stats.
Known limitations
These are practical edges to work around, not bugs.
ENDS WITHon a broad suffix is a full scan.ENDS WITH "google.com"(no leading dot, very common suffix) does not finish. Use a selective, leading-dot suffix (ENDS WITH ".cloudflare.com"), or for subdomain enumeration traverseCHILD_OFfrom the anchored parent instead.CONTAINSonASN.nameis not index-backed.ASN.nameis the AS number (AS13335), and a bareCONTAINSover the ASN label scans it, so the query does not come back. Anchor the ASN exactly ({name: "AS13335"}), reach it throughROUTES/HAS_NAMEfrom a known prefix, or filter the network name onASN_NAME.CONTAINSonHOSTNAME.nameis fast when the query is already anchored.CONTAINSand regex (=~) on a large label are scans. They run, but slowly, and a regex over a billion-node label does not finish. Regex is full-match, with no nested quantifiers. PreferSTARTS WITH/ENDS WITH, and always add aLIMIT.UNWINDintoCALL explain()makes one backend call per item. It works and is the right way to score a list, but runtime scales with the list length — keep the unwound list short.- BGP routing history over a large network is slow.
whisper.history()on a big ASN can take many seconds. Keep aLIMITand expect a longer round trip. shortestPathrequires a bounded path length. Always write the variable-length range explicitly, for example[*1..6]. A high bound widens the search and slows the query.- A wide fan-out does not get slower, it stops finishing. Anchor on the most selective node in the pattern, stage wide hops behind
WITH, and put aLIMITon anything exploratory. - Subdomain WHOIS history is empty. WHOIS is captured per registrable domain —
whisper.history("www.cloudflare.com")returns nothing. Use the parent domain (cloudflare.com). - Anycast IPs — One IP, many physical locations. GeoIP returns a single nominal location for the whole anycast set, not the edge a user reached:
1.1.1.1answersSydney, AUand8.8.8.8answersMountain View, US. You get one confident-looking city, not an empty result, so an anycast address never announces itself as unlocatable. Read the owning ASN's country instead, and draw no geographic conclusion from GeoIP on anycast, mobile-carrier NAT, or VPN-exit IPs. - MOAS conflicts need context. Real hijacks and legitimate anycast both produce a multi-origin (MOAS) conflict. WhisperGraph reports it via
CONFLICTS_WITH; interpretation depends on RPKI status, ASN reputation, and history. - A query that "returns nothing" is often a bad label or edge name. The graph uses
HOSTNAME(neverDomainorFQDN),IPV4/IPV6, andASN/PREFIX, and the property is alwaysname. CheckCALL db.labels()andCALL db.relationshipTypes()when a result looks empty — both are cheap and both answer immediately.
Where to go next
- Use Cases — copy-paste recipes that already follow these rules, organized by workflow.
- Graph Schema — every label, edge, direction, and property.
- Procedures — full signatures for
explain,variants,history, andorigins. - Cheat Sheet — the one-page dense reference.