Skip to contentSkip navigation

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 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"} on billion-node labels. 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. Confirm with EXPLAIN — a NodeLookup leaf means the anchor is indexed. Unanchored scans on these labels do not finish.
  • 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.
  • Write synthesized edges as explicit single hops. ROUTES, ANNOUNCED_BY, LISTED_IN, BELONGS_TO, PEERS_WITH, and CONFLICTS_WITH are computed at query time. Variable-length expansion over them is expensive, not broken: write them as explicit single hops joined with WITH — not because [*..] fails, but because it will not finish.
  • Anchor synthesized edges on the physical side. LISTED_IN expands 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 WITH and selective, leading-dot ENDS WITH over regex. Both are index-backed on .name. Avoid CONTAINS on ASN.name — it scans.
  • Use OPTIONAL MATCH for sparse WHOIS fields. Many domains have partial or redacted registration data; a plain MATCH drops the whole row when one piece is missing.
  • Always add a LIMIT, including on CALL ... YIELD ... RETURN. Use count() to size a result before pulling it, and never run a global edge count — use the stats endpoint instead.
  • Use UNWIND for 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_TO gives the allocated (RIR) prefix; ANNOUNCED_BY gives the BGP-announced prefix. Use ANNOUNCED_BY when 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_TO traversals. 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(), and whisper.history() answer the hardest questions in one call — usually faster and cleaner than a hand-written deep traversal.

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)-[: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().

bash
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 thisNot that
MATCH (h:HOSTNAME {name: "x.com"})MATCH (h:HOSTNAME) WHERE h.name CONTAINS "x"
Anchor, then WITH ... LIMIT, then expandOne deep all-in-one pattern
ENDS WITH ".cloudflare.com" (selective)ENDS WITH "cloudflare.com" (broad scan)
count(p) for a magnitude questioncount(DISTINCT p) when you just need order of magnitude
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)
[: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:

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)
PEERS_WITHASN ↔ 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 networkCALL explain("AS13335") — scored verdict + factors + sources
STARTS WITH / regex sweeps for lookalike domainsCALL whisper.variants("brand.com")
Reconstructing WHOIS or BGP timelines by handCALL whisper.history("indicator")
Chasing real IPs behind a CDNCALL 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() 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.

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 WITH on 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 traverse CHILD_OF from the anchored parent instead.
  • CONTAINS on ASN.name is not index-backed. ASN.name is the AS number (AS13335), and a bare CONTAINS over the ASN label scans it, so the query does not come back. Anchor the ASN exactly ({name: "AS13335"}), reach it through ROUTES / HAS_NAME from a known prefix, or filter the network name on ASN_NAME. CONTAINS on HOSTNAME.name is fast when the query is already anchored.
  • CONTAINS and 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. Prefer STARTS WITH / ENDS WITH, and always add a LIMIT.
  • UNWIND into CALL 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 a LIMIT and expect a longer round trip.
  • shortestPath requires 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 a LIMIT on 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.1 answers Sydney, AU and 8.8.8.8 answers Mountain 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 (never Domain or FQDN), IPV4 / IPV6, and ASN / PREFIX, and the property is always name. 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.
  • Graph Schema — every label, edge, direction, and property.
  • Procedures — full signatures for explain, variants, history, and origins.
  • Cheat Sheet — the one-page dense reference.