This website uses cookies

Read our Privacy policy and Terms of use for more information.

What it actually is

Amazon Neptune is a managed graph database whose storage layer is a distributed, log-structured volume shared by one writer and up to 15 read replicas. Strip the marketing and that sentence is the whole product: it is the same architectural idea as Aurora (compute instances in front, a cluster volume behind), pointed at a graph engine instead of a relational one. The volume keeps six copies of your data across three Availability Zones, grows on its own in 10 GB segments up to 128 TiB in most Regions, and does not care how many instances you attach to it.

What you actually buy is index structures tuned for traversal. In a relational database, "friends of friends of friends" is a chain of joins whose cost explodes with depth. In a graph database, an edge is a first-class thing you walk from a node you already hold. Neptune is built so that walking is cheap and the number of hops is a query detail, not a schema redesign.

The thing people get wrong on day one: Neptune is not one graph model, it is two. It speaks the property graph model (queried with Gremlin or openCypher) and the RDF model (queried with SPARQL). They live in the same cluster, but they are separate data universes. A vertex you create with openCypher is invisible to SPARQL. Pick a model before you load anything.

The model

Property graph. Nodes (vertices) and relationships (edges), each with labels or types and key/value properties. A route between two airports is a relationship with a distance property. This is the model most application developers want, and it is what we build below.

RDF. Everything is a triple of subject, predicate, object, with global identifiers (IRIs). It shines when you integrate data from many sources under a shared vocabulary, publish linked data, or need standards-based inference-style modeling. If your problem statement contains "ontology" or "knowledge graph over many datasets", look here.

Query languages. Gremlin is an imperative traversal language: you describe the walk step by step. openCypher is declarative pattern matching in the MATCH (a)-[:ROUTE]->(b) style, which reads like the picture you would draw on a whiteboard. SPARQL queries RDF. Gremlin and openCypher both run against the property graph, so you can mix them on the same data. For a team starting fresh, openCypher is the lowest-friction choice; reach for Gremlin when you need its traversal steps or already have Gremlin code.

Cluster shape. A Neptune cluster has one writer instance and up to 15 read replicas, all attached to the same volume. Adding a replica does not copy the graph, because the volume is shared, so it is quick. If the writer fails, Neptune promotes a replica; requests to the writer fail briefly during promotion. With no replica, the cluster is down until the writer is recreated, which takes considerably longer. Replicas also carry a promotion tier (0 to 15) that controls failover priority.

Instances: provisioned or serverless. You can pick fixed instance classes, or Neptune Serverless, where each instance scales between a minimum and maximum number of NCUs (Neptune capacity units). One NCU is roughly 2 GiB of memory plus matching CPU and networking, minimum 1.0, maximum 128.0, and the smallest maximum you may set is 2.5. Capacity moves in increments as small as 0.5 NCU. Serverless needs engine release 1.2.0.1 or later. Only compute scales: the storage volume is independent.

What AWS operates vs what you operate. AWS runs the storage layer, replication, repair of damaged segments, backups, patching windows, and failover. You operate the data model, the load pipeline, query design, instance sizing, IAM policy, and network placement. A Neptune cluster is reachable only from inside a VPC; there is no public endpoint.

When to use it, when not to

If your problem is...

Use

Why

Deep relationship traversal: fraud rings, recommendations, network topology, identity graphs

Neptune

Hops are cheap and the query mirrors the question

Mostly key lookups and range scans on items

DynamoDB

Single-digit hops of known keys do not need a graph engine

Relational reporting with a few joins

Aurora / RDS

A graph database adds a second model and query language for no gain

Graph analytics and vector similarity over a large in-memory graph

Neptune Analytics

A separate in-memory engine; Neptune Database is the transactional store

Free-text search with relevance scoring

OpenSearch

Neptune is not a search engine

The honest test: write the query you need in SQL. If it needs more than three or four self-joins, or the number of joins is unknown at design time (find everything reachable within N hops), a graph is the right tool. If your data is mostly independent rows, it is not.

Note the naming. "Neptune Database" is the transactional service this article covers. "Neptune Analytics" is a different engine for analytic workloads and bills differently (per memory-optimized NCU-hour, with a paused graph billed at a fraction of compute). They are not interchangeable.

What it costs

Neptune Database bills along four dimensions, and one of them is the surprise.

Instance hours. Provisioned instances bill per hour by class; for scale, the pricing page's worked example prices a db.r5.large at $0.348/hour in us-east-1. Serverless bills per second for the NCUs in use. I could not confirm the per-NCU-hour rate from the pricing page while researching, so look it up for your Region before you size anything. Note that serverless has a floor: the minimum is 1.0 NCU, so it never scales to zero.

Storage. $0.10 per GB-month in us-east-1 on the Standard storage type. A new cluster starts at 10 GB allocated.

I/O (the surprise). On Standard storage you pay $0.20 per million I/O requests in us-east-1. A graph traversal that walks many edges drives a lot of I/O, and teams that only budgeted for instances discover the line item late. The I/O-Optimized storage type removes I/O charges in exchange for a higher instance price and a higher storage rate; the pricing page says it can save up to 40 percent on I/O-intensive workloads and makes sense when I/O is more than about 25 percent of your total database spend. Measure your I/O before choosing.

Backups and transfer. Backup storage is free up to 100 percent of your cluster storage, then $0.021 per GB-month. Data transfer into Neptune is free, and the first 100 GB out per month is free in the page's example.

Free tier. New customers get 750 hours of db.t3.medium or db.t4g.medium, 10 million I/Os, 1 GB of storage and 1 GB of backup for 30 days. That covers this tutorial if you use a t4g.medium.

Following along: a cluster with one serverless instance held between 1 and 2.5 NCU for an hour or two, a few KB of data, and a t4g.nano client costs cents to low single-digit dollars. The reason to follow the Cleanup section is that the serverless floor means an idle cluster still bills every hour.

The limits that bite

Several limits come straight from the Neptune limits page.

  • Storage: 128 TiB per cluster in most Regions (64 TiB in China and GovCloud).

  • HTTP payload: requests to the Gremlin and SPARQL HTTP endpoints must be under 150 MB. Larger ones return BadRequestException. For big data, use the bulk loader, not giant queries.

  • Property size: 55 MB per individual property or label. Do not store images; store them in S3 and keep the key.

  • WebSocket connections: up to 32,768 on larger instances and serverless, but only 512 on db.t3.medium and db.t4g.medium. Connection storms from many app containers hit small instances first and get throttled.

  • WebSocket lifetime: idle connections are closed after 20 to 25 minutes, and with IAM auth enabled connections are cut a few minutes after 10 days. Reconnect logic is mandatory.

  • Bulk loader: the queue holds 64 jobs, history keeps the last 1,024 jobs, and 10,000 errors are stored per job. The loader is non-ACID.

  • Networking: VPC only, TLS 1.2 required, and a DB subnet group needs at least two AZs.

  • Auth: there are no database usernames and passwords. Access control is IAM database authentication, which requires SigV4-signed requests.

  • Serverless: the lookup cache does not work on serverless instances, and the memory ceiling is 256 GB (128 NCUs).

Build it

We build a small route network: airports as nodes, direct routes as relationships. We provision a Neptune Serverless cluster, load CSVs from S3 with the bulk loader, then run openCypher queries through the neptunedata SDK client, which signs requests with SigV4 for you.

I assembled this from the current Neptune documentation (openCypher load format, loader API, neptunedata boto3 reference, IAM data-access policies). I did not execute it end to end for this article, so treat the first run as a test and expect to adjust names for your account.

Prerequisites

  • An AWS account and a Region (the examples use us-east-1).

  • Python 3.10+ with boto3 on the machine that will run the data-plane steps.

  • That machine must have network access to the cluster: Neptune has no public endpoint. The simplest route is a small EC2 instance in the same VPC, launched with the same security group the cluster uses. In the default VPC, the default security group allows traffic from itself, so attaching it to both is enough.

  • An S3 gateway VPC endpoint in the VPC. The bulk loader reads S3 through it, and without it loads fail.

IAM permissions

Your admin identity needs to create the cluster, instances, subnet group, IAM role, bucket and VPC endpoint. For data access, attach a policy like the one in the Neptune docs to the identity that runs the queries:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "neptune-db:ReadDataViaQuery",
        "neptune-db:WriteDataViaQuery",
        "neptune-db:DeleteDataViaQuery",
        "neptune-db:GetEngineStatus",
        "neptune-db:GetQueryStatus",
        "neptune-db:CancelQuery"
      ],
      "Resource": "arn:aws:neptune-db:us-east-1:ACCOUNT_ID:CLUSTER_RESOURCE_ID/*"
    }
  ]
}

The resource ARN uses the cluster resource ID (the cluster-XXXXXXXX value), not the ARN assigned when you created the cluster. These fine-grained actions need engine release 1.2.0.0 or later. Note the docs say this policy does not cover features such as the bulk loader, so use an admin-level test identity for this tutorial and scope it down afterwards using the Neptune IAM documentation.

Step 1: the data

Two files. Node file airports.csv:

:ID,code:String,city:String,:LABEL
a1,JFK,New York,Airport
a2,LHR,London,Airport
a3,CDG,Paris,Airport
a4,DXB,Dubai,Airport
a5,TUN,Tunis,Airport

Relationship file routes.csv:

:ID,:START_ID,:END_ID,:TYPE,km:Int
r1,a1,a2,ROUTE,5540
r2,a2,a3,ROUTE,344
r3,a3,a5,ROUTE,1450
r4,a2,a4,ROUTE,5470
r5,a3,a4,ROUTE,5240
r6,a4,a5,ROUTE,4730

The header rules from the docs: the header row carries system columns (:ID, :LABEL, :START_ID, :END_ID, :TYPE) which are case-sensitive and appear once per file; properties are name:Type; files are UTF-8 CSV; relationship files need an :ID column when userProvidedEdgeIds is true (the default).

Step 2: network, role and bucket

import json, boto3
REGION = "us-east-1"
BUCKET = "neptune-demo-ACCOUNT_ID-us-east-1"   # must be globally unique
ROLE = "NeptuneLoadFromS3"
iam = boto3.client("iam")
s3 = boto3.client("s3", region_name=REGION)
ec2 = boto3.client("ec2", region_name=REGION)
s3.create_bucket(Bucket=BUCKET)
s3.upload_file("airports.csv", BUCKET, "graph/airports.csv")
s3.upload_file("routes.csv", BUCKET, "graph/routes.csv")
trust = {
    "Version": "2012-10-17",
    "Statement": [{
        "Effect": "Allow",
        "Principal": {"Service": "rds.amazonaws.com"},
        "Action": "sts:AssumeRole",
    }],
}
role = iam.create_role(RoleName=ROLE, AssumeRolePolicyDocument=json.dumps(trust))
iam.put_role_policy(
    RoleName=ROLE,
    PolicyName="read-graph-bucket",
    PolicyDocument=json.dumps({
        "Version": "2012-10-17",
        "Statement": [{
            "Effect": "Allow",
            "Action": ["s3:Get*", "s3:List*"],
            "Resource": [f"arn:aws:s3:::{BUCKET}", f"arn:aws:s3:::{BUCKET}/*"],
        }],
    }),
)
ROLE_ARN = role["Role"]["Arn"]
vpc = ec2.describe_vpcs(Filters=[{"Name": "isDefault", "Values": ["true"]}])["Vpcs"][0]["VpcId"]
rts = ec2.describe_route_tables(Filters=[{"Name": "vpc-id", "Values": [vpc]}])["RouteTables"]
ec2.create_vpc_endpoint(
    VpcId=vpc,
    ServiceName=f"com.amazonaws.{REGION}.s3",
    VpcEndpointType="Gateway",
    RouteTableIds=[rt["RouteTableId"] for rt in rts],
)

The trust policy principal is rds.amazonaws.com: Neptune assumes the role through the RDS service principal.

Step 3: the cluster

neptune = boto3.client("neptune", region_name=REGION)
subnets = [s["SubnetId"] for s in ec2.describe_subnets(
    Filters=[{"Name": "vpc-id", "Values": [vpc]}])["Subnets"]]
neptune.create_db_subnet_group(
    DBSubnetGroupName="neptune-demo",
    DBSubnetGroupDescription="demo",
    SubnetIds=subnets,
)
default_sg = ec2.describe_security_groups(Filters=[
    {"Name": "vpc-id", "Values": [vpc]},
    {"Name": "group-name", "Values": ["default"]},
])["SecurityGroups"][0]["GroupId"]
neptune.create_db_cluster(
    DBClusterIdentifier="neptune-demo",
    Engine="neptune",
    DBSubnetGroupName="neptune-demo",
    VpcSecurityGroupIds=[default_sg],
    StorageEncrypted=True,
    IamDatabaseAuthenticationEnabled=True,
    ServerlessV2ScalingConfiguration={"MinCapacity": 1.0, "MaxCapacity": 2.5},
    DeletionProtection=False,
)
neptune.create_db_instance(
    DBInstanceIdentifier="neptune-demo-writer",
    DBInstanceClass="db.serverless",
    Engine="neptune",
    DBClusterIdentifier="neptune-demo",
)
neptune.add_role_to_db_cluster(
    DBClusterIdentifier="neptune-demo",
    RoleArn=ROLE_ARN,
    FeatureName="s3Import",
)
neptune.get_waiter("db_instance_available").wait(DBInstanceIdentifier="neptune-demo-writer")
cluster = neptune.describe_db_clusters(DBClusterIdentifier="neptune-demo")["DBClusters"][0]
print(cluster["Endpoint"], cluster["Port"], cluster["DbClusterResourceId"])

Three details to respect. MinCapacity 1.0 and MaxCapacity 2.5 are the smallest legal range. The instance class for serverless is db.serverless. And the role association (add_role_to_db_cluster, feature s3Import) must complete before you start a load; wait until describe_db_clusters shows the role as active.

Step 4: load the data

Run this from a machine inside the VPC. The neptunedata client uses your credentials to sign requests.

import time, boto3
from botocore.config import Config
ENDPOINT = "https://CLUSTER_ENDPOINT:8182"
client = boto3.client(
    "neptunedata",
    endpoint_url=ENDPOINT,
    region_name="us-east-1",
    config=Config(read_timeout=None, retries={"total_max_attempts": 1}),
)
job = client.start_loader_job(
    source=f"s3://{BUCKET}/graph/",
    format="opencypher",
    s3BucketRegion="us-east-1",
    iamRoleArn=ROLE_ARN,
    userProvidedEdgeIds=True,
    failOnError=True,
    parallelism="MEDIUM",
)
load_id = job["payload"]["loadId"]
while True:
    st = client.get_loader_job_status(loadId=load_id)["payload"]["overallStatus"]
    print(st["status"], st.get("totalRecords"), st.get("totalTimeSpent"))
    if st["status"] != "LOAD_IN_PROGRESS":
        break
    time.sleep(3)

Note the SDK parameter is s3BucketRegion, while the raw REST loader request calls the same thing region. The source is a prefix, so both CSVs under graph/ load in one job. Node IDs must be unique across files, and relationship :START_ID/:END_ID values must match node IDs that exist. If you hit missing-node errors, check the IDs in both files first.

The loader client settings mirror the Neptune guidance: no client-side read timeout and a single attempt, so the server-side neptune_query_timeout governs duration and a retry cannot duplicate a write.

Step 5: traverse it

def q(text):
    return client.execute_open_cypher_query(openCypherQuery=text)["results"]
# 1. Sanity check: count what loaded
print(q("MATCH (a:Airport) RETURN count(a) AS airports"))
print(q("MATCH ()-[r:ROUTE]->() RETURN count(r) AS routes"))
# 2. Direct routes out of London
print(q("MATCH (:Airport {code:'LHR'})-[r:ROUTE]->(b:Airport) "
        "RETURN b.code AS dest, r.km AS km ORDER BY r.km"))
# 3. All itineraries from JFK to TUN with at most 3 legs, shortest total distance first
print(q("""
MATCH p = (a:Airport {code:'JFK'})-[:ROUTE*1..3]->(b:Airport {code:'TUN'})
RETURN [n IN nodes(p) | n.code] AS path,
       reduce(total = 0, r IN relationships(p) | total + r.km) AS km
ORDER BY km
"""))

Query three is the point of the whole exercise. The variable-length pattern [:ROUTE*1..3] asks "any chain of one to three routes", the hop count is a parameter, and no join was written. The same question in SQL would be three queries unioned, or a recursive CTE.

Verification

You are done when all of these hold:

  • The loader status ends in LOAD_COMPLETED and totalRecords is non-zero with no errors reported (the exact count depends on how the loader counts properties).

  • Query one returns 5 airports and 6 routes.

  • Query two returns CDG (344), DXB (5470) in that order.

  • Query three returns itineraries such as JFK, LHR, CDG, TUN with a total of 7334 km, ahead of longer alternatives. Check that figure by adding the legs: 5540 + 344 + 1450.

The same query over plain HTTP, for debugging with IAM auth on, is awscurl --service neptune-db --region us-east-1 -X POST https://CLUSTER_ENDPOINT:8182/openCypher -d "query=MATCH (n) RETURN n LIMIT 1". With IAM auth off it is a plain curl -d "query=...". Both endpoints require HTTP/1.1.

When it breaks

Connection hangs or times out. Almost always network: you are outside the VPC, the security group does not allow inbound 8182 from your client, or you pointed at a hostname that does not resolve from where you run. Neptune has no public endpoint, so there is nothing to fix on the Neptune side.

403 or "Missing Authentication Token" style signing errors. IAM database auth is on and the request is not signed with SigV4 for the neptune-db service, the credentials expired, or the IAM policy resource uses the wrong ARN. The data-plane resource ARN uses the cluster resource ID, not the cluster ARN.

LOAD_FAILED with S3 access errors. The S3 gateway VPC endpoint is missing, the role is not yet associated with the cluster, the bucket is in a different Region than the cluster, or the objects use SSE-KMS and the role lacks KMS permission. SSE-S3 works without extra setup; SSE-C is not supported.

Load errors on relationships. Check that each relationship file has an :ID column when userProvidedEdgeIds is true, that start and end IDs exist in loaded node files, and that header labels match case exactly. With failOnError true, the load stops at the first bad record, but data loaded up to that point persists, so do not blindly rerun without checking.

LOAD_DATA_DEADLOCK. Parallelism is too aggressive. Lower it (for example HIGH to MEDIUM or LOW) and retry.

Another load is running. A second loader request fails unless you set queueRequest to true, which queues up to 64 jobs.

Duplicates behave oddly. Duplicate node rows are merged and labels combine, but for a property with conflicting values one value is picked non-deterministically. Duplicate relationship IDs load only one relationship. Keep IDs unique.

Serverless instance slow after a spike. Scaling increments depend on current capacity; a higher minimum gives larger steps. If your workload bursts, raise MinCapacity rather than hoping it scales fast enough.

Cleanup

Delete in reverse order, then confirm.

neptune.delete_db_instance(DBInstanceIdentifier="neptune-demo-writer", SkipFinalSnapshot=True)
neptune.get_waiter("db_instance_deleted").wait(DBInstanceIdentifier="neptune-demo-writer")
neptune.delete_db_cluster(DBClusterIdentifier="neptune-demo", SkipFinalSnapshot=True)
neptune.delete_db_subnet_group(DBSubnetGroupName="neptune-demo")
for key in ("graph/airports.csv", "graph/routes.csv"):
    s3.delete_object(Bucket=BUCKET, Key=key)
s3.delete_bucket(Bucket=BUCKET)
iam.delete_role_policy(RoleName=ROLE, PolicyName="read-graph-bucket")
iam.delete_role(RoleName=ROLE)
# remove the S3 gateway endpoint you created, then terminate the client EC2 instance

Then verify: describe_db_clusters should return no cluster named neptune-demo, describe_vpc_endpoints should no longer list your S3 endpoint, and the EC2 client instance should be terminated. Check Cost Explorer a day later; the serverless floor of 1 NCU means a forgotten cluster would keep billing every hour. Also check for leftover automated backups or manual snapshots, which bill beyond the free allowance.

Where to go next

Once the model clicks, the practical questions are data modeling (what is a node versus a property), index-friendly query shapes, and whether you need a separate engine for analytics. The Neptune user guide's openCypher compliance and Gremlin sections cover language edges, and the Neptune Analytics docs cover the in-memory engine for algorithms and vector search.

Sources