# Best Practices

> Anchor, bound and traverse WhisperGraph Cypher for speed: performance habits, a do-this-not-that table, edge directions and the graph's working rules.

*Source: https://www.whisper.security/docs/cypher/best-practices*
*Published: 2026-05-04*
*Last updated: 2026-10-01*

---
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.8B 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](/docs/whisper-graph/schema); for ready-made recipes, the [Workflows](/docs/workflows) and the [cross-cutting recipes](/docs/recipes/cross-cutting).

> **Read `coverage` before `band`.** Only `known-clean` licenses the word "clean"; `no-data` means
> *unknown*, which is a different thing again; `malicious-evidenced` and `ambiguous` mean there is
> evidence, whatever the band says.
> Full contract: [Coverage — what we looked at](/docs/whisper-graph/procedures/coverage).

## 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](https://console.whisper.security/sign-in?redirect_url=https%3A%2F%2Fwww.whisper.security%2Fdocs%2Fcypher%2Fbest-practices) 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 `{name: "..."}`, which holds the path and is indexed, or bound it with `WITH u LIMIT n`, before you expand; `{path: "..."}` and `{id: "..."}` scan the whole label, and 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 expect=rows>0 seed=104.21.112.1 verified=2026-09-02
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 expect=rows>0 seed=github.com verified=2026-09-02
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 expect=rows>0 verified=2026-09-02
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 `{name}` (the path) 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](/docs/whisper-graph/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 `{name}` (the path), 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`.** `{name: "..."}` (the path) 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. Do not use an id to find a node again: `{id: "..."}` scans the whole label, and on `URL` the same id can name a different kit on the next call. Anchor on `{name: "..."}` instead.
- **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

- **[Workflows](/docs/workflows)** — copy-paste recipes that already follow these rules, organized by workflow.
- **[Cross-cutting recipes](/docs/recipes/cross-cutting)** — the patterns that keep large or repetitive jobs fast.
- **[Graph Schema](/docs/whisper-graph/schema)** — every label, edge, direction, and property.
- **[Procedures](/docs/whisper-graph/procedures)** — full signatures for `explain`, `assess`, `enrich`, `identify`, `variants`, `history`, and `origins`.
- **[Cheat Sheet](/docs/cypher/cheat-sheet)** — the one-page dense reference.
