Earlier posts in this series built their own graphs, either from Cypher or from a CSV, on a database we ran ourselves. This one takes a different route and shows how Neo4j can be applied to a realistic problem, crime investigation, without any local setup at all. We use the Crime Investigation dataset that Neo4j provides as a ready-made sandbox: a temporary, cloud-hosted database that comes pre-loaded with data and opens straight into Neo4j Browser.
Creating a Neo4j Sandbox online
A Neo4j Sandbox is a free, short-lived Neo4j instance that runs in the cloud and is reached entirely through the browser. Because it is disposable and pre-populated, it is the quickest way to explore a themed dataset without installing anything or writing the import script yourself.
To create one, go to https://sandbox.neo4j.com, sign in (a free account is enough), and choose a project from the list of available datasets. For this post we pick Crime Investigation, which builds a graph modelled on real police data and seeds it with officers, crimes, people, and the places and devices that tie them together. Launching the project provisions a dedicated database in the background; after a few moments the sandbox is listed as running and ready to use.
A sandbox is deliberately temporary: it is kept for a few days and then recycled, so it is meant for learning and experimentation rather than for storing anything you need to keep. That is exactly what we want here, since the dataset is provided for us and we only need it for the length of this walkthrough.
Loading the crime investigation dataset
Selecting the Crime Investigation project is all that is needed to load the data: the sandbox imports the sample crime dataset automatically as it starts up. There is no LOAD CSV step and no Cypher import script to run, which is the main convenience of working from a sandbox rather than an empty database.
Once the instance shows as running, open Neo4j Browser from the sandbox controls and click sandbox login to connect. Browser is the same query console used elsewhere in this series: a Cypher editor at the top, a results frame below it, and a details panel on the right that shows the properties of whatever node or relationship is selected. With the connection made, the full dataset is in place and we can begin by looking at its shape.
Inspecting the schema
Before writing any queries it is worth understanding what kinds of things the graph contains and how they connect. As with any Neo4j database, the built-in schema procedure summarises this for us:
CALL db.schema.visualization()
CALL runs a stored procedure rather than a pattern match, and db.schema.visualization returns two collections: one node per label the database has seen, and one arrow per relationship type between those labels. Browser draws them as a small graph, so the result is a meta-graph in which each circle is a category of node, not an individual record, and each arrow is a category of connection.
db.schema.visualization(). The labels appear as coloured circles, Officer, Crime, Person, Location, PostCode, Area, Object, Vehicle, Email, Phone, and PhoneCall, with the relationship types between them listed in the bottom panel.
Figure 1 lays out the whole domain at a glance. Reading the arrows tells a small story about how the data is organised:
- A crime is investigated by an officer (
Crime-[INVESTIGATED_BY]->Officer). - A person is party to a crime (
Person-[PARTY_TO]->Crime), which covers anyone involved, whether suspect, witness, or victim. - A crime occurred at a location (
Crime-[OCCURRED_AT]->Location), and a person’s current address is also a location (Person-[CURRENT_ADDRESS]->Location). - People know each other through several relationship types (
KNOWS,KNOWS_LW,KNOWS_SN,FAMILY_REL,KNOWS_PHONE), each recording a different kind of link. - Further nodes such as
Email,Phone, andPhoneCallcapture the communications that connect people, whilePostCodeandAreaplace eachLocationon the map.
It is worth pausing on the schema before moving on. Knowing the direction of each relationship is what lets you write patterns that actually match: PARTY_TO runs from Person to Crime, and INVESTIGATED_BY from Crime to Officer, so a query that draws either arrow the wrong way round will silently return no rows.
With the shape of the graph clear, we can start the investigation.
Tracing an officer’s caseload
The natural entry point is a single officer. The query below finds every crime being investigated by the officer whose surname is Larive:
//Query: crimes investigated by Officer Larive
MATCH (c:Crime {last_outcome: "Under investigation"})-[
i:INVESTIGATED_BY]->(o:Officer)
WHERE o.surname = 'Larive'
RETURN c, i, o
The pattern reads directly off the schema: (c:Crime) binds every crime, -[:INVESTIGATED_BY]->(o:Officer) follows the investigation arrow to the officer on the other end, and the WHERE clause keeps only the officer we care about. Returning both c and o gives Browser the nodes and the relationship between them, so the result is drawn as a graph rather than a table.
Browser lets us make that result easier to read without touching the query. Selecting the Crime label and setting its caption to the type property labels each crime with its category, and its colour can be changed to whatever is easiest to scan. With those adjustments the picture shows that Officer Larive is investigating eight crimes, three of which fall into the drugs category.
Crime node captioned by its type. The officer sits at the centre and the eight surrounding crimes fan out along INVESTIGATED_BY relationships; three of them are labelled Drugs, and the details panel on the right shows the properties of the selected crime.
Figure 2 makes the caseload easy to scan at a glance: the category captions turn an otherwise anonymous cluster of nodes into a readable summary of what Officer Larive is working on.
Identifying the officer by surname keeps this query reproducible, since the name is visible in the data. An officer can equally be pinned down by their unique badge_no, which is the safer choice when two officers might share a surname; we use exactly that identifier in the shortest-path query later on.
Focusing on the drug crimes
Eight crimes are more than we need. Adding one more condition to the WHERE clause narrows the result to just the drug-related cases:
//Query: drug crimes investigated by Officer Larive
MATCH (c:Crime {
last_outcome: "Under investigation",
type:"Drugs"})-[i:INVESTIGATED_BY]->(o:Officer)
WHERE o.surname = 'Larive'
RETURN c, i, o
The only change is adding type: "Drugs" to the crime pattern. Both conditions must hold for a crime to be kept, so the eight crimes collapse to the three of type Drugs that Officer Larive is handling. This is the subset we investigate from here on.
INVESTIGATED_BY. Selecting one shows its properties on the right, including a charge of Possession of Cannabis with Intent to Supply and a last_outcome of Under investigation.
Figure 3 is the focused view we wanted: the wider caseload has dropped away and only the drug cases remain, which is the small, meaningful starting set the rest of the investigation builds on.
Expanding the network
A crime node on its own tells us little; its value lies in what it connects to. In Browser, double-clicking a node expands it, pulling in its immediate neighbours. Expanding each of the three drug crimes brings in the people who are party to them and the locations where they occurred; setting the caption on the Person and Location nodes (to name and address respectively) keeps the growing picture legible.
Two useful observations fall out of this straight away:
- Two of the drug crimes happened at the same location. A shared address across separate cases is the kind of coincidence an investigator wants to notice.
- One person, Jack, is party to both of those crimes. A single individual linking two cases is a strong lead, and surfacing it visually can save an officer a great deal of manual cross-referencing.
Expanding the third drug crime brings in a different individual, Raymond. So the three drug cases involve two people of interest, Jack and Raymond, and the obvious next question for an investigator is how these two are connected.
Finding how two people are connected
To see whether Jack and Raymond sit in the same social circle, we look for the shortest paths between them through the various “knows” relationships. The query below does this for every pair of people party to Officer Larive’s drug crimes, rather than for Jack and Raymond by hand, so it generalises to any such investigation. It is worth reading a clause at a time.
// Shortest Path between any two Person partying to crime
MATCH (c:Crime {
last_outcome: 'Under investigation',
type: 'Drugs'})-[:INVESTIGATED_BY]->(:Officer {badge_no: '26-5234182'}),
(c)<-[:PARTY_TO]-(p:Person)
WITH COLLECT(p) AS persons
UNWIND persons AS p1
UNWIND persons AS p2
WITH * WHERE elementId(p1) < elementId(p2)
MATCH path = allShortestPaths((p1)-[
:KNOWS|KNOWS_LW|KNOWS_SN|FAMILY_REL|KNOWS_PHONE*..3]-(p2))
RETURN path
Matching the right crimes. The first MATCH selects only the crimes that are still Under investigation and of type Drugs, and that are handled by the specific officer, here identified by badge_no rather than surname. This pins the query to the exact slice of the caseload we have been exploring.
Finding the people involved. The second MATCH, (c)<-[:PARTY_TO]-(p:Person), follows the PARTY_TO arrow backwards from each crime to the people party to it, whether suspects, witnesses, or otherwise relevant. collect(DISTINCT p) gathers them into a single list named persons, which is what lets us compare them against one another in the next step.
Building unique pairs. UNWIND turns a list back into rows, and unwinding persons twice produces every combination of two people, p1 and p2. The condition WHERE elementId(p1) < elementId(p2) keeps only one ordering of each pair and drops the cases where p1 and p2 are the same node, so we avoid both duplicate pairs and self-loops. elementId returns each node’s stable string identifier and replaces the deprecated id function; any consistent ordering works here, since all we need is to pick one member of each pair.
Searching for connections. allShortestPaths then finds the shortest route between each pair, following any of the relationships KNOWS, KNOWS_LW (knows and lives with), KNOWS_SN (knows through a social network), FAMILY_REL (a family relationship), or KNOWS_PHONE (knows via phone records). The *..3 bound allows paths of up to three hops, which is wide enough to reveal indirect connections through mutual acquaintances without letting the search wander across the whole graph.
Returning the result. RETURN path hands back every path found. Each one is a chain of relationships linking two people in the investigation, and Browser draws them together as a connected subgraph.
For Jack and Raymond, the search shows they are not directly connected but are linked through a third-degree chain, with Brian, Phillip, Allen, and Kathleen appearing as the intermediaries between them. That connection, invisible in any single case file, is exactly the kind of insight a graph makes cheap to find.
KNOWS, FAMILY_REL, KNOWS_SN, and KNOWS_LW relationships. The details panel identifies the selected node as Raymond Walker.
Figure 4 is the payoff of the whole walkthrough: two people who never appear together in a single case file turn out to share a tight circle of mutual acquaintances, a lead that would be laborious to piece together by hand but falls straight out of a single path query.
allShortestPaths returns all the equally short routes between a pair, which is what you want in an investigation: a single connection might be coincidental, but several independent short paths between two people are far harder to dismiss. Using shortestPath instead would return just one route and hide the others.
Conclusion
Working entirely inside a Neo4j Sandbox, we started from nothing more than an officer’s surname and ended with a concrete, non-obvious link between two people of interest. The pattern is the one that makes graphs valuable for investigative work: read the schema to learn the vocabulary of the domain, match a small, meaningful starting set, expand outward to see what each node touches, and then let a path query surface connections that no single record reveals on its own. The same three steps, filter, expand, and connect, carry over to fraud detection, contact tracing, and any other problem where the relationships matter as much as the entities themselves.