Best Practices
The habits that make WhisperGraph queries return in milliseconds, the traps that look like habits and are not, and the graph's working rules written as instructions.
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 wide fan-out expanded before it was bounded, 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 7.5B nodes and 39.5B 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 and the cross-cutting recipes.
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"}and a label.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, and confirm withEXPLAIN. Unanchored scans on these labels do not finish. - Lowercase the anchor value in your own code, not in the query. Names are stored lowercase, so
{name: "Volerion.com"}matches nothing. Wrapping the anchor intoLower(...)defeats the index and turns an instant lookup into a scan. Strip a trailing dot the same way:gmail.com.is not thegmail.comnode. - Always add a
LIMIT, including onCALL ... YIELD ... RETURN. Usecount()to size a result before pulling it, and read graph-wide totals from the stats endpoint instead of counting edges. - Batch instead of looping. If you have a list of indicators, send one query with
UNWINDor hand the whole list to a procedure (whisper.assess([...]),whisper.enrich([...])). Each unwound element stays an anchored lookup, and one round trip beats fifty. - Reach for the procedures first.
explain(),whisper.assess(),whisper.enrich(),whisper.identify(),whisper.origins(),whisper.variants(), andwhisper.history()do the hardest joins server-side, usually faster and cleaner than a hand-written deep traversal. - 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. - Decompose a deep chain. Split it into anchored stages joined by
WITH ... LIMITwith one computed hop per stage, push a multi-hop leg into aCALL { ... }subquery with its ownLIMIT, or let a procedure such aswhisper.enrich()do the join server-side. A long single traversal needs an account, so sign in before you run one. - Mind the direction of mail and nameserver edges: they point server → domain. A domain's mail servers are
(domain)<-[:MAIL_FOR]-(mx), not the other way around. - Prefer plain
count()overcount(DISTINCT ...)for yes/no and order-of-magnitude questions. It is faster and the magnitude is usually what you want. - Quote every procedure argument.
CALL whisper.identify(ubuntu.com)is a bad-argument error, and an unquoted IPv6 literal is parsed as something else entirely. AlwaysCALL whisper.identify("ubuntu.com"). - Bound the input before you
collect.collect(DISTINCT x)[0..N]slices after the whole list is built, so putWITH x LIMIT nin front of the aggregation. - Group on a coarse key. A grouped result is as large as the number of distinct groups, and a trailing
LIMITdoes not shrink it. Group by country rather than city, ASN rather than prefix, category rather than feed, or bound the input withWITH ... LIMITbefore you aggregate. - Use
STARTS WITHand a narrow, leading-dotENDS WITHoverCONTAINSand regex. Both are index-backed on.name.CONTAINSis fine once the query is anchored or paired withSTARTS WITH; never run it across an unanchored label. For an unclassified token useCALL whisper.search("token"),STARTS WITH, or a narrow suffix.ASN.nameis the AS number, so match it exactly or withSTARTS WITH "AS". - Use
OPTIONAL MATCHfor sparse WHOIS and geo fields. Many domains have partial or redacted registration data; a plainMATCHdrops the whole row when one piece is missing. - Pick the right IP-to-prefix edge.
BELONGS_TOgives the allocated (RIR) prefix;ANNOUNCED_BYgives the BGP-announced prefix. To reach the routing ASN, walk(ip)-[:ANNOUNCED_BY]->(:ANNOUNCED_PREFIX)<-[:ROUTES]-(asn); do not joinROUTESandBELONGS_TOin one pattern. - Walk computed edges forward from a stored anchor.
ASN → ROUTES → ANNOUNCED_PREFIXandIPV4 → LISTED_IN → FEED_SOURCEare the fast directions.LISTED_INalso answers from an anchored feed ((f:FEED_SOURCE {name: "..."})<-[:LISTED_IN]-(ip)), but the fan-out is the whole feed, so keep theLIMITtight. - Anchor
URLnodes beforeLINKS_TO.LINKS_TOis the URL → HOSTNAME edge that says which hosts serve a phishing-kit path. Anchor the URL by{path: "..."}or{id: "..."}, or bound it withWITH u LIMIT n, before you expand; a bareMATCH (u:URL)-[:LINKS_TO]->(h)returns nothing. The hostname-to-hostname hyperlink edges are a small sample, not a web layer; do not plan a link-graph question on them.
Anchor, then bound
MATCH (ip:IPV4 {name: "104.21.112.1"})<-[:RESOLVES_TO]-(sib:HOSTNAME)
WITH sib LIMIT 200
MATCH (sib)-[:HAS_REGISTRAR]->(r:REGISTRAR)
RETURN r.name AS registrar, count(*) AS domains
ORDER BY domains DESC LIMIT 10
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.
The same shape decomposes a deep routing walk. Bound the DNS stage, then run the routing leg inside a CALL { } subquery with its own LIMIT:
MATCH (h:HOSTNAME {name: "github.com"})-[:RESOLVES_TO]->(ip:IPV4)
WITH ip LIMIT 3
CALL {
WITH ip
MATCH (ip)-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)<-[:ROUTES]-(a:ASN)
RETURN ap, a LIMIT 1
}
RETURN ip.name AS ip, ap.name AS prefix, a.name AS asn
LIMIT 5
If you prefer plain WITH stages, give each one a single computed hop: ANNOUNCED_BY in one stage, ROUTES in the next.
Never count edges from an unanchored pattern
A whole-graph aggregate like MATCH ()-[r]->() RETURN count(r) is refused: the engine answers 400 query-unservable with reason: "global_edge_count" and points you at the precomputed figures. Read totals 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
CALL db.relationshipTypes() YIELD type, count RETURN type, count ORDER BY type LIMIT 5
Both answer immediately. The same call also emits sourceLabels and targetLabels, which makes it the fastest way to check an edge's direction before you write the pattern.
Do this, not that
| Do this | Not that |
|---|---|
MATCH (h:HOSTNAME {name: "x.com"}) | MATCH (h:HOSTNAME) WHERE h.name CONTAINS "x" |
Lowercase in your code: {name: "volerion.com"} | WHERE toLower(h.name) = "volerion.com" |
Anchor, then WITH ... LIMIT, then expand | One deep all-in-one pattern |
UNWIND $names AS n MATCH (h:HOSTNAME {name: n}) | One request per indicator, or one very long IN list |
ENDS WITH ".cloudflare.com" (narrow) | ENDS WITH "cloudflare.com" (broad scan) |
CALL whisper.search("token") for an unclassified token | WHERE n.name CONTAINS "token" across a whole label |
count(p) for a magnitude question | count(DISTINCT p) when you just need order of magnitude |
WITH x LIMIT 200 and then collect(DISTINCT x) | collect(DISTINCT x)[0..20] over an unbounded fan-out |
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) |
(ip)-[:ANNOUNCED_BY]->(:ANNOUNCED_PREFIX)<-[:ROUTES]-(asn) | (asn)-[:ROUTES]->(:PREFIX)<-[:BELONGS_TO]-(ip) in one pattern |
count(DISTINCT p) across an announced-prefix chain | RETURN DISTINCT p.name, a.name across the same chain |
-[:BGP_NEIGHBOR]-(n) WHERE n <> a | -[:PEERS_WITH]->(n) (older alias, one direction) |
[:CHILD_OF*1..3] (bounded) | [:CHILD_OF*] (unbounded) |
CALL whisper.identify("ubuntu.com") | CALL whisper.identify(ubuntu.com) |
CALL db.relationshipTypes() YIELD type | ... YIELD relationshipType (not a column) |
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) |
ANNOUNCED_BY / ROUTES | IP → ANNOUNCED_PREFIX; ASN → prefix | IP to origin AS: (ip)-[:ANNOUNCED_BY]->(ap)<-[:ROUTES]-(asn) |
LISTED_IN | IP/HOSTNAME → FEED_SOURCE | a feed's members: (f)<-[:LISTED_IN]-(ip), with a tight LIMIT |
LINKS_TO | URL → HOSTNAME | anchor the URL by {path} or {id} first |
BGP_NEIGHBOR | ASN ↔ ASN (symmetric in practice) | write it undirected and filter WHERE n <> a |
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 |
| Scoring a list one indicator at a time | CALL whisper.assess(["a", "b", "c"]) — verdict plus coverage per host |
| Joining owner, country, ASN and band yourself for a mixed list | CALL whisper.enrich(["1.1.1.1", "github.com"]) — one row per indicator, joined server-side |
| Guessing whose infrastructure a host is from its ASN | CALL whisper.identify("host") — vendor, canonical name, roles |
CONTAINS across a label to classify a token | CALL whisper.search("token") — a bounded, typed lookup |
STARTS WITH / regex sweeps for lookalike domains | CALL whisper.variants("brand.com") |
| Reconstructing WHOIS or BGP timelines by hand | CALL whisper.history.whois("domain") / CALL whisper.history.bgp("AS…") |
| Chasing real IPs behind a CDN | CALL whisper.origins("domain.com") |
explain() in particular replaces the pattern that fails most often — a scan down a large ASN's prefixes to find threat listings, which does not finish. 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.
An unknown label or edge name in an anchored pattern matches nothing rather than erroring. Legacy labels from other graph products are the exception: Domain, IpAddress, and Certificate are rejected with an error that names the label to use (HOSTNAME, IPV4, CT_OBSERVATION). A legacy edge name such as IN_ASN or HAS_PTR is not an error; it simply matches nothing, so check CALL db.relationshipTypes() before you conclude the graph has no data.
Two more shapes look like an answer and are not. A RETURN DISTINCT projection across an announced-prefix chain (ANNOUNCED_BY, then ROUTES) comes back empty where the same query without DISTINCT returns rows; aggregate with count(DISTINCT ...) or de-duplicate in your client. And MATCH (u:URL)-[:LINKS_TO]->(h) with no anchor on the URL returns nothing; anchor it by {path} or {id}, or bound it with WITH u LIMIT n.
Known limitations
These are the working rules of the graph, written as instructions.
- Keep
ENDS WITHnarrow and on hostnames. A leading-dot, multi-label suffix (ENDS WITH ".cloudflare.com") is indexed; a bare, common suffix (ENDS WITH "google.com") reads the whole label. OnlyHOSTNAMEcarries the suffix index, so onPREFIXorASNanchor instead. For subdomain enumeration, traverseCHILD_OFfrom the anchored parent. - Never
CONTAINSan unanchored label. Substring search across a whole label is not a supported access path. UseCALL whisper.search("token", {mode: "prefix"})for a bounded prefix hunt,STARTS WITHon an indexed name, orwhisper.searchwith a narrowsuffixoption.CONTAINSonASN.nameis never the way: the name is the AS number, so useSTARTS WITH "AS"or the exact value, or filter the network name onASN_NAMEreached throughHAS_NAME. - Regex is a full match and rarely indexed.
=~has to cover the whole value, and it is only planned as an index lookup when it is a plain prefix or a.*literal.*shape. PreferSTARTS WITH/ENDS WITH, keep=~for rows you have already anchored, and always add aLIMIT. - Score a list with
UNWIND ... CALL explain()orwhisper.assess([...]). Both are the right way to score many indicators; runtime grows with the list, so send lists rather than loops and keep each list to what you need. - Give
whisper.historyone shape at a time. WHOIS columns and routing columns never share a row, soYIELDfrom one shape, or callwhisper.history.whois(domain)/whisper.history.bgp(ip|asn|prefix)directly.YIELD *is rejected on the multi-shape form, as it is onexplain. Routing history for a large network is a slow read; keep aLIMITand expect a longer round trip. - Ask WHOIS history for the registrable domain. WHOIS is captured per registrable domain.
whisper.history.whois("www.cloudflare.com")folds up tocloudflare.comand says so in awhois-parent-foldadvisory; pass the parent yourself when you can. - Bound
shortestPathexplicitly. Write the variable-length range ([*1..4]) and keep it tight; a high bound widens the search. No path within the bound is an empty result, not an error. - 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 ... LIMIT, and put aLIMITon anything exploratory. - Aggregate across an announced-prefix chain; do not
RETURN DISTINCTover it. AcrossANNOUNCED_BYthenROUTES, usecount(DISTINCT ...)or de-duplicate client-side. - Type the relationship when expanding outward from an announced prefix. From
ANNOUNCED_PREFIXorREGISTERED_PREFIX, write<-[:ROUTES]-,-[:CONFLICTS_WITH]->, or-[:HAS_COUNTRY]->; or start from thePREFIX-labelled node of the same name, where an untyped-[r]->is fine. - Walk IP → prefix with
ANNOUNCED_BY. To reach the routing ASN from an address, use(ip)-[:ANNOUNCED_BY]->(:ANNOUNCED_PREFIX)<-[:ROUTES]-(asn). Do not joinROUTESandBELONGS_TOin one pattern; the announced and allocated prefix planes do not join that way. - Anchor
URLnodes beforeLINKS_TO.{path: "..."},{id: "..."}, orWITH u LIMIT nfirst. ROAhas noname. Reach a ROA throughROA_AUTHORIZES_ORIGINorROA_AUTHORIZES_PREFIXfrom an anchored ASN or prefix, then read.prefix,.asn, and.maxLength.RIRis node-only. Nothing joins to theRIRlabel; read an ASN's registry fromASN.autNumSourceRirinstead.- Never treat row one of an unanchored scan as representative. A few placeholder nodes carry names like
..and sort ahead of real data. Anchor the query, or filterWHERE n.name CONTAINS "."on rows you have already bounded. - Node ids are strings. Compare them with
id(a) = id(b); an ordering comparison matches nothing.properties(n).idis the same string and round-trips into{id: "..."}on id-keyed labels such asURL, quoted or as a string parameter. - Anycast and CDN IPs often have no geolocation, or one nominal city. One address, many physical locations: GeoIP cannot tell you the edge a user reached. Read the owning ASN's country instead, and draw no geographic conclusion from GeoIP on anycast, mobile-carrier NAT, or VPN-exit addresses.
- 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. - Read the
advisories[]channel. A successful response can carry a top-leveladvisories[]array besidecolumns,rows, andstatistics, each entry akindand amessage:null-pagination-param,projection-verdict-omitted,enrich-semantics,whois-parent-fold,schema-drift-rewrite, and others. It survives anyYIELD/RETURNprojection and is omitted when there is nothing to say, so test for its presence and read it rather than parsing rows for hints. - Project verdict fields explicitly, or ask for the full projection.
RETURN nmay leave out the reconciled verdict fields for speed and say so with aprojection-verdict-omittedadvisory.RETURN n.verdictLevelnames the field;projectionFull: trueon the request returns the full verdict surface forRETURN n. - Use the current names.
Domain,IpAddress, andCertificateare rejected with an error naming the replacement (HOSTNAME,IPV4,CT_OBSERVATION). Legacy edge names such asIN_ASNorHAS_PTRmatch nothing: reverse DNS is(ip)<-[:RESOLVES_TO]-(h), and the origin AS is reached throughANNOUNCED_BYandROUTES. The property is alwaysname, and the graph usesHOSTNAME(neverDomainorFQDN),IPV4/IPV6, andASN/PREFIX. 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.
- Cross-cutting recipes — the patterns that keep large or repetitive jobs fast.
- Graph Schema — every label, edge, direction, and property.
- Procedures — full signatures for
explain,assess,enrich,identify,variants,history, andorigins. - Cheat Sheet — the one-page dense reference.