Lightweight Edges

A lightweight edge is an edge stored as a pair of pointers inside the two vertices it connects, with no record of its own. It therefore carries no properties. A regular edge is a first-class record with its own RID, able to hold arbitrary key/value properties.

The storage shape is declared on the edge type, so every way of creating an edge — SQL, Cypher, Gremlin, the Java API, GraphBatch, the async executor — produces the shape the schema asks for.

CREATE EDGE TYPE Follows LIGHTWEIGHT
database.getSchema().buildEdgeType().withName("Follows").withLightweight(true).create();

When to use them

Use lightweight edges when both of the following hold:

  • the relationship carries no properties, and

  • you mostly traverse from vertices rather than querying the edge type directly.

An edge that needs a weight, a timestamp or any other attribute must be a regular edge.

Querying the edge type directly does work — SELECT FROM <EdgeType> returns the edges — but it costs more than on a regular type, because there are no records to scan and no index to consult: the query reads the vertices instead. If that is your main access pattern, prefer a regular edge type. See Querying a lightweight edge type.

Lightweight edges do not make traversal faster. Expanding from a vertex reads the neighbour’s RID straight out of the edge list and never loads the edge record — in either shape. Choose lightweight edges for write throughput and disk footprint, not for read speed.

Measured performance

Embedded, single writer, warm page cache. Two graph shapes with the same operations. Bulk insert is one transaction per 20K edges; Single insert is one transaction per edge; Delete removes all edges of a sample of vertices.

Table 1. Degree 10 — 20,000 vertices, 200,000 edges
Shape UNIQUE Bulk insert Single insert 1-hop 2-hop Delete Disk per edge

Regular

no

570 ms

299 ms

19 ms

23 ms

113 ms

81.6 B

Regular

yes

1,162 ms

343 ms

19 ms

24 ms

126 ms

123.3 B

Lightweight

no

494 ms

195 ms

21 ms

25 ms

71 ms

45.9 B

Lightweight

yes

494 ms

188 ms

19 ms

22 ms

56 ms

45.9 B

Table 2. Degree 100 — 500 vertices, 50,000 edges
Shape UNIQUE Bulk insert Single insert 1-hop 2-hop Delete Disk per edge

Regular

no

123 ms

35 ms

2 ms

101 ms

217 ms

59.0 B

Regular

yes

203 ms

46 ms

2 ms

100 ms

273 ms

133.9 B

Lightweight

no

35 ms

19 ms

2 ms

100 ms

147 ms

24.9 B

Lightweight

yes

47 ms

19 ms

2 ms

100 ms

147 ms

24.9 B

Reading the numbers:

  • Writes get faster as degree grows: bulk insert is 1.2x faster than regular edges at degree 10 and 3.5x faster at degree 100. Single-edge insert is 1.5-1.8x faster, deletion 1.5-1.6x.

  • Disk drops 44-58%, and more against a regular type that also carries a UNIQUE index.

  • Traversal is unchanged in every configuration — see the note above.

Limitations

  • No properties. Declaring a property on a LIGHTWEIGHT edge type, or passing properties to newEdge(), is rejected with a schema error.

  • No inherited constraint that needs a record. A lightweight edge is never validated, so a MANDATORY or default-valued property would be declared and never applied. Since v26.10.2 the schema refuses to let one reach a lightweight type through a super type: CREATE EDGE TYPE Child EXTENDS Parent LIGHTWEIGHT and ALTER TYPE Child SUPERTYPE +Parent are rejected while Parent declares or inherits such a property, so are ALTER TYPE Child WITH lightweight = true on a type that does, and so is making a property of a super type MANDATORY or giving it a default while a lightweight edge type sits below it. Optional properties, and ones that only constrain a value that is present (NOT NULL, MIN, MAX, REGEXP), are still inherited.

    A database whose schema already holds the combination, written by an earlier version, still opens and the constraint stays declared and unenforced. Since v26.10.2 opening it logs a WARNING naming the lightweight type and the property it cannot apply, and CHECK DATABASE lists the same finding under warnings. The message names every such property with its way out: a MANDATORY one is made optional (ALTER PROPERTY Parent.since MANDATORY false), a default-valued one has its default removed (ALTER PROPERTY Parent.weight DEFAULT null, which fills in nothing and is therefore accepted under a lightweight type). Declaring the type without LIGHTWEIGHT clears the finding too.

  • No records, therefore no usable RID. Every lightweight edge of a type reports the same placeholder @rid, so you cannot address one by RID. See Querying a lightweight edge type for what this costs.

    A lightweight edge still answers its metadata wherever metadata is asked for: (since v26.10.2) a result map, the SQL include() and exclude() methods (include('@rid', '@in', '@out')), and the HTTP graph and studio serializers give @cat, @type, @rid, @in and @out, with the same keys as a record-backed edge. In v26.10.1 and earlier a lightweight edge read off the graph answered an empty map there.

  • Grants still apply. (Since v26.10.2) the createRecord and deleteRecord grants of a LIGHTWEIGHT edge type are enforced, on every path that creates or deletes such an edge (SQL CREATE EDGE and DELETE, openCypher, Gremlin, Vertex.newEdge(), Edge.delete(), GraphBatch). Deleting a vertex that holds lightweight edges needs deleteRecord on their edge type, as for a record-backed edge. In v26.10.1 and earlier both grants were ignored on a lightweight type, since no record is written. See Groups.

  • Identity is the triple (edge type, out vertex, in vertex). With no properties there is no observable difference between two lightweight edges of the same type over the same ordered pair, so they are the same edge.

Querying a lightweight edge type

These all work and agree with each other:

SELECT FROM Follows
SELECT count(*) FROM Follows
DELETE FROM Follows

Because a lightweight edge has no record, none of them can read the edge type’s own storage: they read the vertices that hold the edges. Two consequences are worth knowing before you rely on them:

  • count() is not instant.* On a regular type it answers from a stored counter; on a lightweight type no such counter can be correct, so the count reads the graph. A throttled WARNING in the server log tells you when this happens on a large database.

  • LIMIT does not make it cheap. There is nothing to seek into, so a small LIMIT may still read a large part of the vertex set.

Anchoring the query on the vertices stays the cheapest way to reach these edges, and is unchanged:

SELECT expand(outE('Follows')) FROM Person
SELECT FROM Follows WHERE @out = #12:0

TRAVERSE …​ FROM <EdgeType> and TRUNCATE TYPE <EdgeType> work the same way, reading the vertices behind the scenes:

TRAVERSE in, out FROM Follows
TRUNCATE TYPE Follows

TRUNCATE TYPE on a lightweight edge type with a subtype needs POLYMORPHIC (e.g. TRUNCATE TYPE Follows POLYMORPHIC); without it, the statement refuses rather than guessing whether you meant the subtype’s edges too. A lightweight edge has no records of its own, so there is no way to scope the command to that type alone.

This applies to the type you name, not to the whole hierarchy: a regular edge type that merely has a lightweight subtype below it truncates normally, clearing its own records and leaving the subtype’s edges untouched, as it would with any other subtype. Since v26.10.1 - earlier versions refused that case too.

Duplicates

ArcadeDB does not prevent a second lightweight edge over the same ordered pair unless the type declares UNIQUE, because the check costs a scan of the source vertex’s edge list. If an application creates one anyway, the behaviour is well defined and nothing is corrupted:

  • traversals return the edge twice;

  • path uniqueness (Cypher relationship uniqueness, the path procedures) counts each copy as a relationship of its own: a path may walk one copy and then the other, but never the same copy twice. This holds for every Cypher pattern form: fixed-length hops, variable-length (-[:K*2]-, -[:K*1..3]-), quantified (a)-[:K]-(b{2}), pattern predicates and pattern comprehensions. Since v26.10.2 - earlier versions treated the copies as one edge and dropped such paths. The two copies have no property to tell them apart, so they are told apart by their place in the edge list they were read from;

  • comparing or deduplicating relationship values sees each copy as a relationship of its own: r1 = r2 is false for two copies, and DISTINCT r, grouping, UNION, count(DISTINCT r), collect(DISTINCT r) and path equality keep every copy. A Cypher DELETE r removes and counts every copy it is given. A DELETE that meets a copy reads its remaining rows in before deleting, within the per-query heap limit (arcadedb.queryMaxHeapElementsAllowedPerOp): over the limit the statement fails and deletes nothing. Since v26.10.2 - earlier versions saw the copies as one, and a DELETE over them could leave copies behind (#10008);

  • id() of a lightweight edge is null: it has no record, so there is no position to encode (earlier versions raised an error);

  • each delete() removes one copy, so the delete must be repeated once per copy;

  • CHECK DATABASE reports the count under duplicateLightEdges. It is reported, never auto-fixed: the two copies live in the source vertex’s outgoing list and the target’s incoming list, which are repaired in separate passes.

If you need two distinguishable edges between the same pair of vertices, you need a property to tell them apart — which means a regular edge type.

The UNIQUE declaration

UNIQUE means at most one edge of this type per ordered (out, in) pair.

CREATE EDGE TYPE Follows LIGHTWEIGHT UNIQUE
ALTER TYPE Follows WITH unique = true

It is enforced differently depending on the storage shape, and the cost is the opposite of what most people expect:

Type shape Mechanism Insert cost Disk cost

Lightweight

Scan of the source vertex’s edge list

None at degree 10, ~35% at degree 100

None

Regular

Unique index on (@out, @in)

+65% to +100%

Roughly 2x per edge

Uniqueness is therefore cheap on lightweight edges and expensive on regular ones. On a regular type the index is created for you and cannot be dropped while the type declares UNIQUE — withdraw the declaration with ALTER TYPE <name> WITH unique = false, which drops the flag and the index together.

Declaring UNIQUE on an already-populated regular type builds the index over the existing data, so it fails with a DuplicatedKeyException naming the offending pair if duplicates are already present. On a lightweight type the flag applies to new edges only; use CHECK DATABASE to inspect what is already stored.

Tuning the edge-list chunk size

Every vertex stores its adjacency list (per direction) as a chain of edge chunks. The first chunk is arcadedb.graph.edgeListInitialChunkSize bytes (default 64) and each further chunk doubles the previous one up to 8192, so the space a vertex allocates is the sum of that series.

A smaller first chunk does not necessarily use less space. It takes more chunks to reach the same capacity, and each chunk carries its own record header. The best value depends on the degree distribution, not on whether the edges are lightweight.

Disk per edge, sweeping the first-chunk size (lightweight | regular):

First chunk Degree 2 Degree 10 Degree 100

32 B

72.7 B | 139.6 B

36.0 B | 86.2 B

24.9 B | 59.0 B

64 B (default)

95.0 B | 128.5 B

45.9 B | 81.6 B

24.9 B | 59.0 B

128 B

164.5 B | 197.3 B

36.7 B | 71.4 B

24.9 B | 57.7 B

256 B

304.1 B | 336.9 B

67.5 B | 99.9 B

23.6 B | 57.7 B

512 B

585.2 B | 618.0 B

129.4 B | 161.9 B

26.2 B | 77.3 B

When to change it:

  • Leave it at the default unless you have measured your degree distribution. There is no value that wins everywhere.

  • Average degree around 10: 128 is worth trying. It is better than the default on every axis for both edge shapes — around 15% faster bulk insert, 12% faster delete, and 20% less disk. The default of 64 is an unlucky fit for this shape: after the chunk header it leaves 51 usable bytes, and a degree-10 adjacency list needs slightly more, so it spills into a second chunk and allocates 192 bytes to hold about 58.

  • Very sparse graphs (average degree 2-3): keep the default, or try 32 for lightweight edges, where it saves about 24% of the disk. Raising the value here is expensive — 128 costs over 70% more space at degree 2.

  • High degree (100+): the setting barely matters; the doubling reaches the same place regardless of where it starts.

Values below 32 are clamped: a chunk smaller than that cannot hold the header plus one entry.

Low-degree graphs pay the most for chunk allocation — 95 bytes per edge at degree 2 against 45.9 at degree 10 for the same lightweight edges — because each vertex allocates a first chunk per direction with very few edges to amortise it over. That is where this setting earns its keep.

See Settings for how to change it per database.

Migrating from the per-call API

The shape used to be chosen per call, with Vertex.newLightEdge() and the withLightEdges flag on GraphBatch, the remote batch builder, the HTTP batch endpoint and the async executor. Those are deprecated. GraphBatch with withLightEdges(true) now raises an IllegalArgumentException for an edge without properties whose type does not declare LIGHTWEIGHT, because such edges were invisible to the count shortcuts.

Declare LIGHTWEIGHT on the type instead. A per-call flag cannot be honoured by SQL, Cypher, the exporter or the UNIQUE constraint, all of which need to know the shape from the schema; a type that declares it is stored lightweight regardless of what any per-call flag says.

See Also