Cuando trabajas con Amazon Neptune, ajustar el rendimiento puede sentirse como avanzar a ciegas dentro de una cueva oscura. Las consultas se vuelven lentas sin razón aparente, o esa traversal de Gremlin que funcionaba perfecto sobre un dataset pequeño tarda una eternidad en un grafo de producción. Por suerte, Neptune nos da una herramienta para encender la luz: el comando EXPLAIN.
En este post vamos a ver cómo usar planes EXPLAIN o PROFILE en los tres lenguajes de consulta que soporta Neptune (Gremlin, SPARQL y openCypher) para diagnosticar problemas de rendimiento, entender la ejecución de las consultas y optimizar tus workloads de grafos.
Por qué importan EXPLAIN y PROFILE
A diferencia de las bases de datos relacionales, las consultas sobre grafos suelen implicar traversals por muchas aristas, filtrado dinámico y matching de patrones. Eso hace que el rendimiento sea más difícil de predecir, y las estrategias tradicionales de indexación no siempre aplican. Ahí entra el query planner: te muestra cómo Neptune está interpretando tu consulta y dónde podría estar perdiendo tiempo.
Cómo generar planes EXPLAIN
Veamos cómo se generan los planes EXPLAIN en cada uno de los lenguajes soportados.
1\. Gremlin
En Gremlin, el plan PROFILE se genera usando el endpoint HTTP profile en el Neptune Workbench o cualquier prompt compatible con Gremlin que soporte las extensiones de Neptune. Usar PROFILE en lugar de EXPLAIN ejecuta la consulta en tiempo real y entrega estadísticas mucho más útiles:
POST https://<your-neptune-endpoint>:<port>/gremlin/profile \
-d '{
"gremlin":"g.V().hasLabel(\"city\")
.has(\"name\", \"London\")
.emit()
.repeat(in().simplePath())
.times(2)
.limit(100)"
}'
Si corres este código en un Jupyter Notebook, también puedes usar el comando de magic cell:
%%gremlin profile
Esto ejecuta la consulta y devuelve un reporte de profile completo, que incluye:
- Pasos de ejecución
- Duraciones y conteos
- Planes de consulta optimizados y físicos
- Métricas de traversal y de repeat
Salida de ejemplo (truncada para mayor claridad):
Query String
==================
g.V().hasLabel("city").has("name", "London").emit().repeat(in().simplePath()).times(2).limit(100)Original Traversal
==================
[GraphStep(vertex,[]), HasStep([...]), RepeatStep(...), RangeGlobalStep(0,100)]Optimized Traversal
===================
Neptune steps:
[\
NeptuneGraphQueryStep(Vertex) {\
JoinGroupNode {\
PatternNode[(?1, <name>, "London", ?) . project ?1 .], {...}\
PatternNode[(?1, <~label>, ?2=<city>, <~>) . project ask .], {...}\
RepeatNode {\
Repeat {\
PatternNode[(?3, ?5, ?1, ?6) . project ?1,?3 . SimplePathFilter(?1, ?3)) .], {...}\
}\
Emit { Filter(true) }\
LoopsCondition { LoopsFilter([?1, ?3], eq(2)) }\
}\
}, finishers=[limit(100)]\
},\
NeptuneTraverserConverterStep\
]Physical Pipeline
=================
NeptuneGraphQueryStep
|-- JoinGroupOp
|-- DynamicJoinOp(...)
|-- RepeatOp
|-- BindingSetQueue (Iteration 1)...
|-- BindingSetQueue (Iteration 2)...
|-- LimitOp(100)Runtime (ms)
============
Query Execution: 392.686
Serialization: 2636.380Traversal Metrics
=================
Step Count Traversers Time (ms) % Dur
--------------------------------------------------------------
NeptuneGraphQueryStep(Vertex) 100 100 314.162 82.78
NeptuneTraverserConverterStep 100 100 65.333 17.22
TOTAL - - 379.495 -Repeat Metrics
==============
Iteration Visited Output Until Emit Next
------------------------------------------------------
0 1 1 0 1 1
1 61 61 0 61 61
2 38 38 38 0 0
------------------------------------------------------
100 100 38 62 62Results
=======
Count: 100
Response size (bytes): 23566
Veamos en qué consiste cada sección para que sepas qué te está mostrando:

2\. SPARQL
En SPARQL se usa el parámetro explain=details junto con la consulta. Así obtienes un plan EXPLAIN detallado con curl (también puedes usar los comandos de magic cell si trabajas en Jupyter):
curl https://<your-neptune-endpoint>:<port>/sparql \
-d "query=PREFIX ex: <https://example.com/> \
SELECT ?person WHERE { ?person a ex:City ; ex:name \"London\" }" \
-d "explain=details"
Salida de ejemplo:
╔════╤════════╤════════╤═══════════════════╤═══════════════════════════════════════════════════════╤══════════╤══════════╤═══════════╤═══════╤═══════════╗
║ ID │ Out #1 │ Out #2 │ Name │ Arguments │ Mode │ Units In │ Units Out │ Ratio │ Time (ms) ║
╠════╪════════╪════════╪═══════════════════╪═══════════════════════════════════════════════════════╪══════════╪══════════╪═══════════╪═══════╪═══════════╣
║ 0 │ 1 │ - │ SolutionInjection │ solutions=[{}] │ - │ 0 │ 1 │ 0.00 │ 0 ║
╟────┼────────┼────────┼───────────────────┼───────────────────────────────────────────────────────┼──────────┼──────────┼───────────┼───────┼───────────╢
║ 1 │ 2 │ - │ PipelineJoin │ pattern=distinct(?person, rdf:type, ex:City) │ - │ 1 │ 2 │ 2.00 │ 1 ║
╟────┼────────┼────────┼───────────────────┼───────────────────────────────────────────────────────┼──────────┼──────────┼───────────┼───────┼───────────╢
║ 2 │ 3 │ - │ PipelineJoin │ pattern=distinct(?person, ex:name, \"London\") │ - │ 2 │ 2 │ 1.00 │ 1 ║
╟────┼────────┼────────┼───────────────────┼───────────────────────────────────────────────────────┼──────────┼──────────┼───────────┼───────┼───────────╢
║ 3 │ 4 │ - │ Projection │ vars=[?person] │ retain │ 2 │ 2 │ 1.00 │ 0 ║
╟────┼────────┼────────┼───────────────────┼───────────────────────────────────────────────────────┼──────────┼──────────┼───────────┼───────┼───────────╢
║ 4 │ - │ - │ TermResolution │ vars=[?person] │ id2value │ 2 │ 2 │ 1.00 │ 1 ║
╚════╧════════╧════════╧═══════════════════╧═══════════════════════════════════════════════════════╧══════════╧══════════╧═══════════╧═══════╧═══════════╝
explain=details ofrece la vista más completa de cómo Neptune planifica y ejecuta internamente las consultas SPARQL.
Veamos en qué consiste cada sección para que sepas qué te está mostrando:

3\. openCypher
Para generar un plan EXPLAIN en openCypher, usa el parámetro explain=details (también puedes usar los comandos de magic cell si trabajas en Jupyter):
curl https://<your-neptune-endpoint>:<port>/openCypher \
-d "query=MATCH (c:City {name: 'London'}) RETURN c" \
-d "explain=details"
Salida de ejemplo:
Query:
MATCH (c:City {name: 'London'}) RETURN c
╔════╤════════╤════════╤═══════════════════╤════════════════════╤═════════════════════╤══════════╤═══════════╤═══════╤═══════════╗
║ ID │ Out #1 │ Out #2 │ Name │ Arguments │ Mode │ Units In │ Units Out │ Ratio │ Time (ms) ║
╠════╪════════╪════════╪═══════════════════╪════════════════════╪═════════════════════╪══════════╪═══════════╪═══════╪═══════════╣
║ 0 │ 1 │ - │ SolutionInjection │ solutions=[{}] │ - │ 0 │ 1 │ 0.00 │ 0 ║
╟────┼────────┼────────┼───────────────────┼────────────────────┼─────────────────────┼──────────┼───────────┼───────┼───────────╢
║ 1 │ 2 │ - │ DFESubquery │ subQuery=subQuery1 │ - │ 0 │ 10 │ 0.00 │ 5.00 ║
╟────┼────────┼────────┼───────────────────┼────────────────────┼─────────────────────┼──────────┼───────────┼───────┼───────────╢
║ 2 │ - │ - │ TermResolution │ vars=[?c] │ id2value_opencypher │ 10 │ 10 │ 1.00 │ 1.00 ║
╚════╧════════╧════════╧═══════════════════╧════════════════════╧═════════════════════╧══════════╧═══════════╧═══════╧═══════════╝subQuery1:
╔════╤════════╤════════╤═════════════════╤═══════════════════════════════════════════════════════════╤══════╤══════════╤═══════════╤═══════╤═══════════╗
║ ID │ Out #1 │ Out #2 │ Name │ Arguments │ Mode │ Units In │ Units Out │ Ratio │ Time (ms) ║
╠════╪════════╪════════╪═════════════════╪═══════════════════════════════════════════════════════════╪══════╪══════════╪═══════════╪═══════╪═══════════╣
║ 0 │ 1 │ - │ DFEPipelineScan │ pattern=Node((?anon_node)-[:?rel]->()) │ - │ 0 │ 1000 │ 0.00 │ 0.66 ║
║ │ │ │ │ inlineFilters=[(?label = :City), (?name = 'London')] │ │ │ │ │ ║
╟────┼────────┼────────┼─────────────────┼───────────────────────────────────────────────────────────┼──────┼──────────┼───────────┼───────┼───────────╢
║ 1 │ 2 │ - │ DFEProject │ columns=[?c] │ - │ 1000 │ 1000 │ 1.00 │ 0.14 ║
╟────┼────────┼────────┼─────────────────┼───────────────────────────────────────────────────────────┼──────┼──────────┼───────────┼───────┼───────────╢
║ 2 │ - │ - │ DFEDrain │ limit=10 │ - │ 1000 │ 0 │ 0.00 │ 0.11 ║
╚════╧════════╧════════╧═════════════════╧═══════════════════════════════════════════════════════════╧══════╧══════════╧═══════════╧═══════╧═══════════╝
explain=details en openCypher muestra etapas de ejecución, lógica de joins, límites y estimaciones de patrones en formato tabular, mucho más útil para análisis de rendimiento que la salida estándar.
Veamos en qué consiste cada sección para que sepas qué te está mostrando:

Cómo interpretar la salida
La salida de PROFILE en Gremlin desglosa tanto las etapas de ejecución como su costo. Las Repeat Metrics resultan especialmente útiles para entender los loops de traversal, una trampa de rendimiento muy común en consultas de grafos. Permiten identificar segmentos costosos del traversal y ver cómo la lógica de filtros o de paths impacta la ejecución.
Para SPARQL y openCypher, el modo details convierte planes estáticos en un análisis detallado paso a paso, con datos como tipos de join, orden de proyección, filtros, costo de tiempo por operador y volúmenes de datos estimados vs. reales.
Caso práctico: optimización de Gremlin en acción
Analicemos un ejemplo simple de consulta en Gremlin. Esta consulta arranca en el nodo que representa la ciudad de Londres. Desde ahí, recorre hacia atrás todas las conexiones entrantes hasta tres pasos de distancia, sin pasar dos veces por el mismo nodo. Luego avanza un paso desde donde haya llegado para encontrar nodos conectados. Después, se queda solo con los eventos (es decir, los que tienen la propiedad type igual a "event"). Por último, devuelve hasta 50 resultados que coincidan. Así se ve la consulta en Gremlin:
g.V()
.has("name", "London")
.hasLabel("city")
.repeat(in().simplePath())
.times(3)
.out()
.has("type", "event")
.limit(50)
Plan EXPLAIN:
*******************************************************
Neptune Gremlin Profile
*******************************************************
Query String
==================
g.V().has("name", "London").hasLabel("city").repeat(in().simplePath()).times(3).out().has("type", "event").limit(50)
Original Traversal
==================
Optimized Traversal
===================
Neptune steps:
[\
NeptuneGraphQueryStep(Vertex) {\
JoinGroupNode {\
PatternNode[(?1, <name>, "London", ?) . project ?1 .], {estimatedCardinality=2, indexTime=75, joinTime=4, hashJoin=true, actualTotalOutput=2} [1]\
PatternNode[(?1, <~label>, ?2=<city>, <~>) . project ask .], {estimatedCardinality=10000, indexTime=33, hashJoin=true, joinTime=0, actualTotalOutput=2} [1]\
RepeatNode {\
Repeat {\
PatternNode[(?3, ?5, ?1, ?6) . project ?1,?3 . IsEdgeIdFilter(?6) . SimplePathFilter(?1, ?3)) .], {estimatedCardinality=70000, hashJoin=true, indexTime=0, joinTime=5} [2]\
}\
Emit {\
Filter(false)\
}\
LoopsCondition {\
LoopsFilter([?1, ?3],eq(3))\
}\
}, annotations={repeatMode=BFS, emitFirst=false, untilFirst=false, leftVar=?1, rightVar=?3}\
}, finishers=[filter(type=event), limit(50)], annotations={path=[Vertex(?1):GraphStep, Repeat[Vertex(?3):VertexStep], Vertex(?4):VertexStep], joinStats=true, optimizationTime=519, maxVarId=9, executionTime=483} [3]\
},\
NeptuneTraverserConverterStep\
]
Physical Pipeline
=================
NeptuneGraphQueryStep
|-- StartOp
|-- JoinGroupOp
|-- SpoolerOp(100)
|-- DynamicJoinOp(PatternNode[(?1, <name>, "London", ?) . project ?1 .], {estimatedCardinality=2, indexTime=75}) [1]
|-- SpoolerOp(100)
|-- DynamicJoinOp(PatternNode[(?1, <~label>, ?2=<city>, <~>) . project ask .], {estimatedCardinality=10000, indexTime=33}) [1]
|-- RepeatOp
|-- <upstream input> (Iteration 0) [visited=2, output=2 (until=0, emit=0), next=2]
|-- BindingSetQueue (Iteration 1) [visited=250, output=250 (until=0, emit=0), next=250]
|-- DynamicJoinOp(PatternNode[(?3, ?5, ?1, ?6) . ...]) [2]
|-- BindingSetQueue (Iteration 2) [visited=950, output=950 (until=0, emit=0), next=950]
|-- BindingSetQueue (Iteration 3) [visited=19500, output=19500 (until=19500, emit=0), next=0]
|-- VertexStep(OUT)
|-- FilterStep(type = event) [3]
|-- LimitOp(50)
Runtime (ms)
============
Query Execution: 483.222
Serialization: 2798.304
Traversal Metrics
=================
Step Count Traversers Time (ms)
------------------------------------------------------------
NeptuneGraphQueryStep 50 50 403.187
NeptuneTraverserConverterStep 50 50 80.035
Repeat Metrics
==============
Iteration Visited Output Until Emit Next
------------------------------------------------------
0 2 2 0 0 2
1 250 250 0 0 250
2 950 950 0 0 950
3 19500 19500 19500 0 0
------------------------------------------------------
20702 20702 19500 0 1202
Warnings:
⚠ reverse traversal with no edge label(s) [2]
⚠ high fan-out detected in repeat [2]
⚠ filter applied late in traversal chain [3]
Lo que muestra el plan EXPLAIN
[x] indica dónde encontrar cada dato dentro del plan EXPLAIN anterior:
- [1] Inicio ineficiente: aunque la cardinalidad es baja, ambos filtros se podrían reordenar para podar mejor el dataset. A mayor cardinalidad, mayor el rango de valores posibles, lo que implica un dataset devuelto más pequeño porque coinciden menos valores. La idea es que primero se devuelva el dataset más pequeño. Por ejemplo, tienes dos filtros sobre potencialmente 10.000 nodos cada uno. Uno selecciona el 50 % de esos nodos y el otro el 10 %. Conviene procesar primero el filtro del 10 % para que solo 1.000 nodos pasen al siguiente filtro. Si lo haces al revés, pasas cinco veces más nodos al siguiente filtro, se procesan más datos y la consulta se vuelve más lenta.
- [2]
.in()no tiene edge label, por lo que escanea todas las aristas entrantes. El traversal del repeat explota en tamaño (de 2 a casi 20.000 nodos). - [3] Los filtros
.out()y.has("type", "event")se aplican después de esa expansión enorme, lo cual es ineficiente. - Conteo total del traversal: más de 20K; mucho esfuerzo desperdiciado.
Versión optimizada
g.V()
.hasLabel("city")
.has("name", "London")
.repeat(in("located_in").simplePath())
.times(3)
.out("hosts")
.has("type", "event")
.limit(50)
Mejoras aplicadas
- Se agregaron filtros de edge label tanto a
.in()como a.out() - Se movió
.has("type", "event")antes para reducir el costo aguas abajo - Se mantuvo
.simplePath()como protección contra ciclos, pero se dejó opcional para pruebas
Salida de PROFILE tras la optimización
*******************************************************
Neptune Gremlin Profile
*******************************************************
Query String
==================
g.V().hasLabel("city").has("name", "London")
.repeat(in("located_in").simplePath()).times(3)
.out("hosts").has("type", "event").limit(50)
Original Traversal
==================
[GraphStep(vertex,[]),\
HasStep([~label.eq(city)]),\
HasStep([name.eq(London)]),\
RepeatStep(emit(false), [VertexStep(IN,[located_in]), PathFilterStep(simple), RepeatEndStep], until(loops(3))),\
VertexStep(OUT,[hosts]),\
HasStep([type.eq(event)]),\
RangeGlobalStep(0,50)]
Optimized Traversal
===================
Neptune steps:
[\
NeptuneGraphQueryStep(Vertex) {\
JoinGroupNode {\
PatternNode[(?1, <~label>, ?2=<city>, <~>) . project ask .], {estimatedCardinality=3000, indexTime=21, actualTotalOutput=7}\
PatternNode[(?1, <name>, "London", ?) . project ?1 .], {estimatedCardinality=1, indexTime=62, actualTotalOutput=1}\
RepeatNode {\
Repeat {\
PatternNode[(?3, <located_in>, ?1, ?) . project ?1,?3 . SimplePathFilter(?1,?3)] {estimatedCardinality=4500, hashJoin=true}\
}\
Emit { Filter(false) }\
LoopsCondition { LoopsFilter([?1, ?3], eq(3)) }\
}, annotations={repeatMode=BFS, emitFirst=false, untilFirst=false, leftVar=?1, rightVar=?3}\
},\
JoinGroupNode {\
PatternNode[(?3, <hosts>, ?4, ?) . project ?4 .], {estimatedCardinality=500, hashJoin=true}\
PatternNode[(?4, <type>, "event", ?) . project ?4 .], {estimatedCardinality=150, hashJoin=true}\
},\
finishers=[limit(50)],\
annotations={executionTime=192, optimizationTime=87, path=[Vertex(?1)->Repeat(?3)->Vertex(?4)]}\
},\
NeptuneTraverserConverterStep\
]
Physical Pipeline
=================
NeptuneGraphQueryStep
|-- StartOp
|-- JoinGroupOp
|-- DynamicJoinOp(PatternNode[(?1, <~label>, ?2=<city>, <~>) ...])
|-- DynamicJoinOp(PatternNode[(?1, <name>, "London", ?) ...])
|-- RepeatOp
|-- Iteration 0: visited=1, output=1, next=1
|-- Iteration 1: visited=35, output=35, next=35
|-- Iteration 2: visited=85, output=85, next=85
|-- Iteration 3: visited=120, output=120, next=0
|-- DynamicJoinOp(PatternNode[(?3, <hosts>, ?4, ?) ...])
|-- DynamicJoinOp(PatternNode[(?4, <type>, "event", ?) ...])
|-- LimitOp(50)
Runtime (ms)
============
Query Execution: 172.329
Serialization: 817.502
Traversal Metrics
=================
Step Count Traversers Time (ms) % Dur
---------------------------------------------------------------------
NeptuneGraphQueryStep 50 50 139.438 80.9
NeptuneTraverserConverterStep 50 50 32.891 19.1
TOTAL 172.329
Repeat Metrics
==============
Iteration Visited Output Until Emit Next
------------------------------------------------------
0 1 1 0 0 1
1 35 35 0 0 35
2 85 85 0 0 85
3 120 120 0 0 0
------------------------------------------------------
241 241 0 0 121
Predicates
==========
# of predicates: 10
Results
=======
Count: 50
Output: [v[302], v[417], v[501], v[519], v[520], v[622], v[635], v[780], v[801], ...]
Response serializer: GRYO_V3D0
Response size (bytes): 18310
Index Operations
================
Query execution:
# of statement index ops: 4
# of unique statement index ops: 4
Duplication ratio: 1.00
# of terms materialized: 0
Serialization:
# of statement index ops: 100
# of unique statement index ops: 88
Duplication ratio: 1.14
# of terms materialized: 145
Resultado:
- Total de nodos recorridos: ~886 vs ~20.702
- Tiempo de consulta reducido en más del 60 %
- Menos presión de memoria y menor riesgo de timeout
TL;DR — Qué buscar en EXPLAIN/PROFILE

Tabla con las áreas clave que aparecen en el plan EXPLAIN
Tips y trucos
- Adelanta los filtros: aplícalos lo antes posible dentro de la consulta.
- Elige bien la dirección: invierte el traversal si así llegas a puntos de partida más selectivos.
- Cuidado con OPTIONAL (SPARQL): puede disparar la complejidad del plan.
- Considera la cardinalidad de los labels: los labels de alta cardinalidad rinden mejor como raíces de la consulta.
- No olvides el diseño del modelo: muchas veces los problemas de rendimiento son síntomas de una estructura de grafo deficiente.
Cómo visualizar los planes de consulta
Para planes grandes, conviene escribir un script en Python que parsee la salida JSON y la renderice como un árbol con Graphviz o D3.js. Es una forma excelente de hacer la estructura más fácil de leer y compartir con tu equipo.
El comando EXPLAIN de Neptune es una de las herramientas más subutilizadas en la caja de quien desarrolla con grafos. Una vez que empiezas a usarlo, te preguntarás cómo trabajaste tanto tiempo sin él. Entender cómo razona el query planner te permite moldear tus consultas, y el grafo en sí, para obtener resultados mejores y más rápidos.
Ahora sí, a depurar esos planes de consulta como un profesional. 🕵️♀️
PD: Si te interesa un follow-up con un deep dive sobre cómo visualizar planes EXPLAIN o hacer benchmarking de rendimiento, avísame — siempre estoy listo para un poco de nerdería de grafos.
En DoiT International, nuestro equipo está formado exclusivamente por talento senior de Engineering. Nos especializamos en consultoría avanzada en la nube, diseño de arquitecturas y servicios de debugging. Ya sea que estés dando tus primeros pasos con bases de datos de grafos, optimizando un sistema existente o resolviendo problemas complejos, te ofrecemos asesoría experta y a medida.