Querying Amazon Neptune with Gremlin
Load property graph data into Amazon Neptune and query relationships with Gremlin traversals.
Amazon Neptune is a managed graph database supporting Gremlin and SPARQL. The examples below focus on Gremlin traversals.
What Is a Graph Database?
Graph databases are optimized for storing and querying relationships between entities. They are useful when traversing interconnected data would require complex joins in a relational model.
In graph databases:
- Vertices represent entities.
- Edges define relationships between vertices.
- Both vertices and edges can have properties (key-value pairs). This model is called a property graph.

Transactions in Neptune
Neptune uses different transaction isolation levels for read-only and mutation queries. See the official documentation for details.
For read-only queries, Neptune uses snapshot isolation with MultiVersion Concurrency Control (MVCC). Each query reads from a snapshot taken at the start of the transaction, preventing dirty reads, non-repeatable reads, and phantom reads.
For mutation queries, Neptune uses READ COMMITTED isolation to prevent dirty reads. It also locks the record ranges being read to prevent non-repeatable reads and phantom reads.
Traversal Examples
Preparing Graph Data
This example uses vertices with an age property and edges with a weight property.

Load the sample data with the following commands. %%gremlin is a Jupyter Notebook magic command available in Neptune Workbench.
%%gremlin// Drop existing datag.V().drop()%%gremlin// Add Verticesg.addV('person').property(id, 'A').property('age', 30) .addV('person').property(id, 'B').property('age', 25) .addV('person').property(id, 'C').property('age', 35) .addV('person').property(id, 'D').property('age', 20) .addV('person').property(id, 'E').property('age', 18) .addV('person').property(id, 'F').property('age', 25) .addV('person').property(id, 'G').property('age', 10) .addV('person').property(id, 'H').property('age', 15)%%gremlin// Add Edgesg.V('A').addE('know').to(g.V('B')).property('weight', 1.0) .V('A').addE('know').to(g.V('C')).property('weight', 0.9) .V('A').addE('know').to(g.V('H')).property('weight', 0.5) .V('B').addE('know').to(g.V('D')).property('weight', 0.8) .V('B').addE('know').to(g.V('E')).property('weight', 0.4) .V('C').addE('know').to(g.V('F')).property('weight', 0.5) .V('C').addE('know').to(g.V('G')).property('weight', 0.6) .V('D').addE('know').to(g.V('E')).property('weight', 0.7) .V('H').addE('know').to(g.V('E')).property('weight', 1.0) .V('H').addE('know').to(g.V('G')).property('weight', 1.0)Example 1: Retrieving All Vertices
%%gremlin// Extract Verticesg.V() .project('id', 'properties') // Projection .by(id()).by(valueMap()) // valueMap returns properties of vertices.Result:
| Row | Data |
|---|---|
| 1 | {'id': 'A', 'properties': {'age': [30]}} |
| 2 | {'id': 'B', 'properties': {'age': [25]}} |
| 3 | {'id': 'C', 'properties': {'age': [35]}} |
| 4 | {'id': 'D', 'properties': {'age': [20]}} |
| 5 | {'id': 'E', 'properties': {'age': [18]}} |
| 6 | {'id': 'F', 'properties': {'age': [25]}} |
| 7 | {'id': 'G', 'properties': {'age': [10]}} |
| 8 | {'id': 'H', 'properties': {'age': [15]}} |
Example 2: Traversing Connections
Retrieve all people aged 25 or older who are connected to 'A' within two hops:
%%gremlin// Extract persons (entities) which are older than 25 years old and connected from A up to 2nd.g.V('A') .repeat(outE().inV()).times(2).emit() // Repeat traversal of adjacent vertices twice .has('age', gte(25)) // Greater than or equal 25 years old .project('id', 'age') .by(id()).by(values('age'))Result:
| Row | Data |
|---|---|
| 1 | {'id': 'B', 'age': 25} |
| 2 | {'id': 'C', 'age': 35} |
| 3 | {'id': 'F', 'age': 25} |

Example 3: Filtering by Weight
Find vertices whose product of edge weights from 'A' exceeds 0.5:
%%gremlin// Start traversal at A which extracts vertices that have a multiplied weight value greater than 0.5g.withSack(1.0f).V('A') // Sack can be used to store temporary data // Multiply a weight value of an out-directed edge by a sack value, and traverse all in-directed vertices .repeat(outE().sack(mult).by('weight').inV().simplePath()).emit() .where(sack().is(gt(0.5))) // A sack value greater than 0.5 .dedup() // deduplication .project('id', 'weight') .by(id).by(sack())Result:
| Row | Data |
|---|---|
| 1 | {'id': 'B', 'weight': 1.0} |
| 2 | {'id': 'C', 'weight': 0.9} |
| 3 | {'id': 'D', 'weight': 0.8} |
| 4 | {'id': 'G', 'weight': 0.54} |
| 5 | {'id': 'E', 'weight': 0.5599…} |

Visualizing Graphs
Neptune Workbench can visualize query results interactively. See the official documentation for details.
Visualizing the graph uses a similar traversal, with display options added:
%%gremlin -d T.id -de weight// -d specifies the vertex property to display// -de specifies the edge property to display
// Execute traversal from example 3g.withSack(1.0f).V('A') // Sack is used to store temporary data .repeat(outE().sack(mult).by('weight').inV().simplePath()).emit() // Traverse with edge weight .where(sack().is(gt(0.5))) // Filter paths where the sack value > 0.5 .dedup() // Remove duplicate paths .path() // Extract path data .by(elementMap()) // Display properties of vertices and edgesExample Output:
| Row | Data |
|---|---|
| 1 | path[{<T.id: 1>: 'A', <T.label: 4>: 'person', 'age': 30}, {<T.id: 1>: '8ebe47fa-901b-c6d3-a11f-0a9bf0ce8aa2', <T.label: 4>: 'know', <Direction.IN: 2>: {<T.id: 1>: 'B', <T.label: 4>: 'person'}, <Direction.OUT: 3>: {<T.id: 1>: 'A', <T.label: 4>: 'person'}, 'weight': 1.0}, {<T.id: 1>: 'B', <T.label: 4>: 'person', 'age': 25}] |
| 2 | path[{<T.id: 1>: 'A', <T.label: 4>: 'person', 'age': 30}, {<T.id: 1>: '7abe47fa-901c-c394-4bed-6dce7defa3f9', <T.label: 4>: 'know', <Direction.IN: 2>: {<T.id: 1>: 'C', <T.label: 4>: 'person'}, <Direction.OUT: 3>: {<T.id: 1>: 'A', <T.label: 4>: 'person'}, 'weight': 0.9}, {<T.id: 1>: 'C', <T.label: 4>: 'person', 'age': 35}] |
After running the query, open the Graph tab in Neptune Workbench to visualize the result. The interface supports dragging and zooming to explore the graph.

Appendix 1: Tree Structures in Relational Databases (RDB)
Tree structures can be modeled in relational databases (RDBs) in several ways, so a graph database is not required for every hierarchical dataset.
However, the Naive Tree model has limitations, as discussed in “SQL Antipatterns”.
- Naive Tree
- Expression:
t1.id = t2.parent_id - Pros
- Simple implementation (Adjacency List)
- Cons
- Difficult to extract non-adjacent nodes
- Complex SQL
- Low performance
- Expression:
- Path Enumeration
- Expression:
path LIKE '1/2%' - Pros
- Simplifies extracting non-adjacent nodes
- Cons
- Complex INSERT/UPDATE/DELETE operations
- Limited by column max length
- Expression:
- Nested Sets
- Expression:
Left > 1 AND Right < 6 - Pros
- Efficient for querying non-adjacent nodes
- Cons
- Complex INSERT/UPDATE operations
- Limited by column max length
- Non-intuitive structure
- Expression:
- Closure Table
- Expression: Separate table for the tree
- Pros
- Efficient for querying all nodes
- Handles INSERT/UPDATE/DELETE easily
- Cons
- Data can grow significantly in size
- INSERT/UPDATE/DELETE can have low performance
In a relational model, queries become more complex as the number of relationship levels increases. Representing and traversing highly connected data with rows and columns may require recursive queries or repeated joins.

Appendix 2: Transactions
Dirty Read
Tx1 updates a row, Tx2 reads the uncommitted value, and then Tx1 fails or rolls back. Tx2 has read data that was never committed.

Non-repeatable Read
Tx1 reads a row, Tx2 updates or deletes it and commits, and then Tx1 reads it again. Tx1 sees a different committed value from its first read.

Phantom Read
Tx1 reads a set of rows, Tx2 inserts or deletes matching rows and commits, and then Tx1 repeats the query. Tx1 sees a different set of rows from its first read.

Isolation Levels
| Level | Dirty Read | Non-repeatable Read | Phantom Read |
|---|---|---|---|
| READ UNCOMMITTED | Possible | Possible | Possible |
| READ COMMITTED | Not possible | Possible | Possible |
| REPEATABLE READ | Not possible | Not possible | Possible |
| SERIALIZABLE | Not possible | Not possible | Not possible |
Conclusion
The examples load a small property graph into Neptune and use Gremlin to query multi-hop relationships and cumulative edge weights.
In example 2, repeat(outE().inV()).times(2).emit() traverses up to two hops from vertex A. In example 3, withSack(1.0f) carries the cumulative weight along each path.
Relational tree models involve different tradeoffs: adjacency lists are simple to update but require more complex queries for distant nodes, while closure tables improve those queries at the cost of additional storage and write processing.
When relationships are a primary part of the query, a graph model can express these traversals more directly than repeated relational joins.
Related posts
Sign in with Slack Using Cognito User Pools and OIDC
Federating Cognito user pools with Slack over OIDC and wiring "Sign in with Slack" into a Next.js app with Amplify.
Deploying FastAPI on AWS Lambda with Lambda Web Adapter
Containerizing a FastAPI backend and deploying it to a single Lambda function with Lambda Web Adapter and AWS CDK.
API Gateway WebSocket: Implementing a Mock Integration
Building an API Gateway WebSocket API entirely with mock integrations, returning canned responses with no backend Lambda involved.
Uploading to S3 Through CloudFront Signed URLs
CloudFront signed URLs let you upload to S3 through a custom domain—useful when direct S3 pre-signed URLs are not an option.
AWS EventBridge Scheduler: Starting and Stopping EC2 on a Schedule
Starting and stopping EC2 instances on a cron schedule with EventBridge Scheduler calling the EC2 API directly, no Lambda involved.
