Skip to contentSkip navigation

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 coverage before band. 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.explain does not return coverage at all. Full contract: Coverage — what we looked at.

The performance habits

  • Anchor every query with {name: "value"} and a label. HOSTNAME and IPV4 are too large to scan. An anchored lookup is an indexed, instant operation that bounds every downstream hop. Inline {name: "..."} and WHERE h.name = "..." are planned identically, as a NodeLookup; prefer the inline form for readability, and confirm with EXPLAIN. 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 in toLower(...) defeats the index and turns an instant lookup into a scan. Strip a trailing dot the same way: gmail.com. is not the gmail.com node.
  • Always add a LIMIT, including on CALL ... YIELD ... RETURN. Use count() 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 UNWIND or 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(), and whisper.history() do the hardest joins server-side, usually faster and cleaner than a hand-written deep traversal.
  • Bound high-fan-out intermediates with WITH ... LIMIT before expanding. Anchor, narrow to a handful of nodes, then traverse outward. A trailing LIMIT caps 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 ... LIMIT with one computed hop per stage, push a multi-hop leg into a CALL { ... } subquery with its own LIMIT, or let a procedure such as whisper.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() over count(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. Always CALL whisper.identify("ubuntu.com").
  • Bound the input before you collect. collect(DISTINCT x)[0..N] slices after the whole list is built, so put WITH x LIMIT n in front of the aggregation.
  • Group on a coarse key. A grouped result is as large as the number of distinct groups, and a trailing LIMIT does not shrink it. Group by country rather than city, ASN rather than prefix, category rather than feed, or bound the input with WITH ... LIMIT before you aggregate.
  • Use STARTS WITH and a narrow, leading-dot ENDS WITH over CONTAINS and regex. Both are index-backed on .name. CONTAINS is fine once the query is anchored or paired with STARTS WITH; never run it across an unanchored label. For an unclassified token use CALL whisper.search("token"), STARTS WITH, or a narrow suffix. ASN.name is the AS number, so match it exactly or with STARTS WITH "AS".
  • Use OPTIONAL MATCH for sparse WHOIS and geo fields. Many domains have partial or redacted registration data; a plain MATCH drops the whole row when one piece is missing.
  • Pick the right IP-to-prefix edge. BELONGS_TO gives the allocated (RIR) prefix; ANNOUNCED_BY gives the BGP-announced prefix. To reach the routing ASN, walk (ip)-[:ANNOUNCED_BY]->(:ANNOUNCED_PREFIX)<-[:ROUTES]-(asn); do not join ROUTES and BELONGS_TO in one pattern.
  • Walk computed edges forward from a stored anchor. ASN → ROUTES → ANNOUNCED_PREFIX and IPV4 → LISTED_IN → FEED_SOURCE are the fast directions. LISTED_IN also answers from an anchored feed ((f:FEED_SOURCE {name: "..."})<-[:LISTED_IN]-(ip)), but the fan-out is the whole feed, so keep the LIMIT tight.
  • Anchor URL nodes before LINKS_TO. LINKS_TO is the URL → HOSTNAME edge that says which hosts serve a phishing-kit path. Anchor the URL by {path: "..."} or {id: "..."}, or bound it with WITH u LIMIT n, before you expand; a bare MATCH (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

cypher · runnablegraph.whisper.securitySign in to run
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:

cypher · runnablegraph.whisper.securitySign in to run
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():

bash
curl -s -A "whisper-client/1.0" https://graph.whisper.security/api/query/stats
cypher · runnablegraph.whisper.securitySign in to run
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 thisNot 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 expandOne 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 tokenWHERE n.name CONTAINS "token" across a whole label
count(p) for a magnitude questioncount(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 totalsMATCH ()-[r]->() RETURN count(r)
OPTIONAL MATCH for WHOIS/geoMATCH 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 chainRETURN 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:

EdgeStored directionTo go the other way
RESOLVES_TOHOSTNAME → IP (forward only)reverse DNS: (ip)<-[:RESOLVES_TO]-(h)
NAMESERVER_FORserver → domaina domain's nameservers: (d)<-[:NAMESERVER_FOR]-(ns)
MAIL_FORserver → domaina domain's MX: (d)<-[:MAIL_FOR]-(mx)
CHILD_OFchild → parenta parent's children: (parent)<-[:CHILD_OF]-(child)
LOCATED_INIP → CITY (then HAS_COUNTRY)chain it: (ip)-[:LOCATED_IN]->(:CITY)-[:HAS_COUNTRY]->(:COUNTRY)
ANNOUNCED_BY / ROUTESIP → ANNOUNCED_PREFIX; ASN → prefixIP to origin AS: (ip)-[:ANNOUNCED_BY]->(ap)<-[:ROUTES]-(asn)
LISTED_INIP/HOSTNAME → FEED_SOURCEa feed's members: (f)<-[:LISTED_IN]-(ip), with a tight LIMIT
LINKS_TOURL → HOSTNAMEanchor the URL by {path} or {id} first
BGP_NEIGHBORASN ↔ 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 networkCALL explain("AS13335") — scored verdict + factors + sources
Scoring a list one indicator at a timeCALL whisper.assess(["a", "b", "c"]) — verdict plus coverage per host
Joining owner, country, ASN and band yourself for a mixed listCALL whisper.enrich(["1.1.1.1", "github.com"]) — one row per indicator, joined server-side
Guessing whose infrastructure a host is from its ASNCALL whisper.identify("host") — vendor, canonical name, roles
CONTAINS across a label to classify a tokenCALL whisper.search("token") — a bounded, typed lookup
STARTS WITH / regex sweeps for lookalike domainsCALL whisper.variants("brand.com")
Reconstructing WHOIS or BGP timelines by handCALL whisper.history.whois("domain") / CALL whisper.history.bgp("AS…")
Chasing real IPs behind a CDNCALL 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() and CALL 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 WITH narrow 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. Only HOSTNAME carries the suffix index, so on PREFIX or ASN anchor instead. For subdomain enumeration, traverse CHILD_OF from the anchored parent.
  • Never CONTAINS an unanchored label. Substring search across a whole label is not a supported access path. Use CALL whisper.search("token", {mode: "prefix"}) for a bounded prefix hunt, STARTS WITH on an indexed name, or whisper.search with a narrow suffix option. CONTAINS on ASN.name is never the way: the name is the AS number, so use STARTS WITH "AS" or the exact value, or filter the network name on ASN_NAME reached through HAS_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. Prefer STARTS WITH / ENDS WITH, keep =~ for rows you have already anchored, and always add a LIMIT.
  • Score a list with UNWIND ... CALL explain() or whisper.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.history one shape at a time. WHOIS columns and routing columns never share a row, so YIELD from one shape, or call whisper.history.whois(domain) / whisper.history.bgp(ip|asn|prefix) directly. YIELD * is rejected on the multi-shape form, as it is on explain. Routing history for a large network is a slow read; keep a LIMIT and 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 to cloudflare.com and says so in a whois-parent-fold advisory; pass the parent yourself when you can.
  • Bound shortestPath explicitly. 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 a LIMIT on anything exploratory.
  • Aggregate across an announced-prefix chain; do not RETURN DISTINCT over it. Across ANNOUNCED_BY then ROUTES, use count(DISTINCT ...) or de-duplicate client-side.
  • Type the relationship when expanding outward from an announced prefix. From ANNOUNCED_PREFIX or REGISTERED_PREFIX, write <-[:ROUTES]-, -[:CONFLICTS_WITH]->, or -[:HAS_COUNTRY]->; or start from the PREFIX-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 join ROUTES and BELONGS_TO in one pattern; the announced and allocated prefix planes do not join that way.
  • Anchor URL nodes before LINKS_TO. {path: "..."}, {id: "..."}, or WITH u LIMIT n first.
  • ROA has no name. Reach a ROA through ROA_AUTHORIZES_ORIGIN or ROA_AUTHORIZES_PREFIX from an anchored ASN or prefix, then read .prefix, .asn, and .maxLength.
  • RIR is node-only. Nothing joins to the RIR label; read an ASN's registry from ASN.autNumSourceRir instead.
  • 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 filter WHERE 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).id is the same string and round-trips into {id: "..."} on id-keyed labels such as URL, 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-level advisories[] array beside columns, rows, and statistics, each entry a kind and a message: null-pagination-param, projection-verdict-omitted, enrich-semantics, whois-parent-fold, schema-drift-rewrite, and others. It survives any YIELD / RETURN projection 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 n may leave out the reconciled verdict fields for speed and say so with a projection-verdict-omitted advisory. RETURN n.verdictLevel names the field; projectionFull: true on the request returns the full verdict surface for RETURN n.
  • Use the current names. Domain, IpAddress, and Certificate are rejected with an error naming the replacement (HOSTNAME, IPV4, CT_OBSERVATION). Legacy edge names such as IN_ASN or HAS_PTR match nothing: reverse DNS is (ip)<-[:RESOLVES_TO]-(h), and the origin AS is reached through ANNOUNCED_BY and ROUTES. The property is always name, and the graph uses HOSTNAME (never Domain or FQDN), IPV4 / IPV6, and ASN / PREFIX. Check CALL db.labels() and CALL 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, and origins.
  • Cheat Sheet — the one-page dense reference.