Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Upsert

Upsert operations replace the values of the predicates you supply for an entity (matched by @id). Repeating an upsert is idempotent (see Idempotency).

What is Upsert?

Upsert = Update or Insert:

  • If the entity exists: for each predicate present in your payload, retract existing values for that predicate and assert the new value(s)
  • If the entity doesn’t exist: create it with the supplied triples

This makes upserts safe to retry: sending the same upsert repeatedly produces the same current-state values for those predicates.

HTTP Endpoint

Use the dedicated upsert endpoint:

curl -X POST "http://localhost:8090/v1/fluree/upsert?ledger=mydb:main" \
  -H "Content-Type: application/json" \
  -d '{
    "@context": {
      "ex": "http://example.org/ns/",
      "schema": "http://schema.org/"
    },
    "@graph": [
      {
        "@id": "ex:alice",
        "@type": "schema:Person",
        "schema:name": "Alice Smith",
        "schema:email": "alice.smith@example.org"
      }
    ]
  }'

Upsert Behavior

First Transaction (Entity Doesn’t Exist)

{
  "@graph": [
    {
      "@id": "ex:alice",
      "@type": "schema:Person",
      "schema:name": "Alice",
      "schema:email": "alice@example.org"
    }
  ]
}

Result: Entity created with specified properties.

Triples After t=1:

ex:alice rdf:type schema:Person
ex:alice schema:name "Alice"
ex:alice schema:email "alice@example.org"

Second Transaction (Entity Exists)

{
  "@graph": [
    {
      "@id": "ex:alice",
      "@type": "schema:Person",
      "schema:name": "Alice Smith",
      "schema:email": "alice.smith@example.org",
      "schema:age": 30
    }
  ]
}

Operations:

  1. For each predicate in the payload (rdf:type, schema:name, schema:email, schema:age), retract every value ex:alice currently holds for it
  2. Assert the payload’s values

A value the payload restates cancels its own retraction, so it does not appear in the commit.

Flakes:

# Retractions (t=2)
ex:alice schema:name "Alice" (retract)
ex:alice schema:email "alice@example.org" (retract)

# Assertions (t=2)
ex:alice schema:name "Alice Smith" (assert)
ex:alice schema:email "alice.smith@example.org" (assert)
ex:alice schema:age 30 (assert)

Triples After t=2:

ex:alice rdf:type schema:Person
ex:alice schema:name "Alice Smith"
ex:alice schema:email "alice.smith@example.org"
ex:alice schema:age 30

rdf:type schema:Person is unchanged: it is retracted and re-asserted in the same transaction, and the two cancel.

Language Tags, Lists, and Blank Nodes

The unit of replacement is the whole (graph, subject, predicate): every current value is retracted exactly as it is stored.

  • Language tags. All languages of a predicate are replaced together. An upsert of {"schema:name": {"@value": "Alicia", "@language": "es"}} over "Alice"@en and "Alice"@fr leaves only "Alicia"@es. To change one language and keep the others, include every language in the payload, or replace that language with an update (Replacing One Language).
  • Lists. A @list value is replaced as a whole: every stored position is retracted, including repeated values, and positions the new list restates cancel. Replacing a list with plain values (or the reverse) works the same way.
  • Blank nodes. A blank node in an upsert payload is identified by the payload itself, so upserting the same document again addresses the same node, and its values are replaced like any other subject’s. An identical re-upsert of a document with blank nodes commits nothing. A changed document mints new blank nodes; the old ones stay (see Stable blank-node ids for editing a stored blank node in place). For Turtle and TriG the identity comes from the parsed statements as they are stored: their order does not count, nor how a term is spelled (a prefixed or full IRI, 1 or "1"^^xsd:integer, true or "true"^^xsd:boolean, a language tag in any case), and a statement written twice counts once. Blank-node labels do count: renaming _:b to _:c gives new nodes. See How RDF text is read for the details and a query that lists blank nodes nothing references.
  • Named graphs. The graph is part of the unit: an upsert into a named graph replaces that graph’s values and leaves the same predicate’s values in other graphs alone.

Replacing One Language

A client that writes one language at a time, such as one prefLabel per language, cannot use upsert for it: each upsert replaces every language of the predicate. It replaces the one language with an update instead, which deletes the stored values in that language and inserts the new one. Over "Alice"@en and "Alice"@fr, this leaves "Alice"@en and "Alicia"@fr:

PREFIX schema: <http://schema.org/>
PREFIX ex: <http://example.org/ns/>

DELETE { ex:alice schema:name ?o }
INSERT { ex:alice schema:name "Alicia"@fr }
WHERE  { OPTIONAL { ex:alice schema:name ?o FILTER(lang(?o) = "fr") } }

The OPTIONAL keeps the insert when no French name is stored yet.

Idempotency

Replace mode is idempotent—repeated submissions produce the same result:

First Submission (t=1):

{"@id": "ex:alice", "schema:name": "Alice", "schema:age": 30}

Result: Entity created.

Second Submission:

{"@id": "ex:alice", "schema:name": "Alice", "schema:age": 30}

Result: nothing is committed. Each value’s retraction and re-assertion cancel, so the transaction stages no flakes and the ledger stays at t=1. This holds for language-tagged values, lists, and the payload’s blank nodes too.

This makes upserts safe to retry, including for a value stored with less detail than it was written: an integer too large for 64 bits written with an XSD subtype such as xsd:nonNegativeInteger (the index keeps no subtype). The upsert retracts such a value as stored and asserts it as written, and storage holds the two as one fact, so they cancel as an unchanged value does. Graph sync behaves the same way. (A dateTime or time keeps six fractional digits from the moment it is written; see Datatypes.)

Comparison: Insert vs Update vs Upsert

Insert

POST /insert?ledger=mydb:main

Behavior:

  • Additive: asserts the triples you submit
  • Does not retract existing values automatically

Example:

t=1: INSERT { ex:alice schema:name "Alice", schema:age 30 }
t=2: INSERT { ex:alice schema:email "alice@example.org" }

Result: ex:alice has name, age, AND email (all three)

Update (WHERE/DELETE/INSERT)

POST /update?ledger=mydb:main

Behavior:

  • Explicit: you retract exactly what you match in where/delete, then assert insert
  • Most flexible (conditional updates, partial updates, computed values)

Example:

t=1: INSERT { ex:alice schema:name "Alice", schema:age 30 }
t=2: UPDATE { DELETE { ex:alice schema:age 30 } INSERT { ex:alice schema:age 31 } WHERE { ex:alice schema:age 30 } }

Result: ex:alice has name "Alice", age 31

Upsert

POST /upsert?ledger=mydb:main

Behavior:

  • Replaces values for the predicates you supply (per subject)
  • Leaves other predicates unchanged
  • Retry-safe/idempotent for the supplied predicates

Use Cases

1. Synchronization from External Systems

Sync data from external database:

async function syncUser(externalUser) {
  await fetch('http://localhost:8090/v1/fluree/upsert?ledger=mydb:main', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      "@graph": [{
        "@id": `ex:user-${externalUser.id}`,
        "@type": "schema:Person",
        "schema:name": externalUser.name,
        "schema:email": externalUser.email,
        "schema:telephone": externalUser.phone
      }]
    })
  })
}

// Safe to call repeatedly—always matches external state
await syncUser(fetchUserFromDB(123));

2. Idempotent API Operations

Make API operations retry-safe:

// Safe to retry on failure
async function updateProduct(productId, productData) {
  return await fetch('http://localhost:8090/v1/fluree/upsert?ledger=mydb:main', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      "@graph": [{
        "@id": `ex:product-${productId}`,
        ...productData
      }]
    })
  })
}

3. Configuration Management

Update configuration atomically:

{
  "@graph": [
    {
      "@id": "ex:config",
      "@type": "ex:Configuration",
      "ex:apiEndpoint": "https://api.example.com",
      "ex:timeout": 30000,
      "ex:retries": 3,
      "ex:enabled": true
    }
  ]
}

Each update replaces entire configuration—no orphaned settings.

4. State Machine Transitions

Model state machines where entity has well-defined state:

{
  "@graph": [
    {
      "@id": "ex:order-123",
      "@type": "ex:Order",
      "ex:status": "shipped",
      "ex:shippedAt": "2024-01-22T10:30:00Z",
      "ex:carrier": "FedEx",
      "ex:trackingNumber": "123456789"
    }
  ]
}

Batch Upserts

Upsert multiple entities:

POST /upsert?ledger=mydb:main
{
  "@context": {
    "ex": "http://example.org/ns/",
    "schema": "http://schema.org/"
  },
  "@graph": [
    {
      "@id": "ex:user-1",
      "@type": "schema:Person",
      "schema:name": "Alice"
    },
    {
      "@id": "ex:user-2",
      "@type": "schema:Person",
      "schema:name": "Bob"
    },
    {
      "@id": "ex:user-3",
      "@type": "schema:Person",
      "schema:name": "Carol"
    }
  ]
}

Each entity is replaced independently.

Type Handling

Types are Preserved

Upsert preserves existing @type values unless you explicitly include @type in the upsert payload (in which case rdf:type is treated like any other predicate and its values are replaced for that subject).

{
  "@graph": [
    {
      "@id": "ex:alice",
      "@type": "schema:Person",
      "schema:name": "Alice"
    }
  ]
}

The @type is always asserted, even if it existed before.

Multiple Types

Entities can have multiple types:

{
  "@graph": [
    {
      "@id": "ex:alice",
      "@type": ["schema:Person", "ex:Employee"],
      "schema:name": "Alice"
    }
  ]
}

All types are replaced together.

Edge Cases

Predicates Not in the Payload

Only the predicates the payload names are replaced:

Before (t=1):

{
  "@id": "ex:alice",
  "schema:name": "Alice",
  "schema:email": "alice@example.org",
  "schema:age": 30
}

Upsert (t=2):

{
  "@id": "ex:alice",
  "schema:name": "Alice Smith"
}

After t=2:

{
  "@id": "ex:alice",
  "schema:name": "Alice Smith",
  "schema:email": "alice@example.org",
  "schema:age": 30
}

To remove a predicate’s values, retract them with WHERE/DELETE/INSERT, or use graph sync to make a whole graph equal a document.

Error Handling

Same Errors as Default Mode

Replace mode has same validation errors:

{
  "error": "ValidationError",
  "message": "Invalid IRI format",
  "code": "INVALID_IRI"
}

No Special Errors

Replace mode doesn’t introduce new error types—it’s just different semantics for the same operations.

Performance Considerations

Retraction Overhead

Replace mode may retract many triples:

Entity with 50 properties:
- 50 retractions
- 50 assertions
= 100 flakes per entity

For entities with many properties, this can be expensive.

Indexing Impact

Each retraction and assertion updates indexes:

  • More work for indexing process
  • May increase indexing lag
  • Consider batch size for large replacements

Best Practices

1. Use for Idempotent Operations

Good use:

// Idempotent sync
await upsertUser(userId, userData);
await upsertUser(userId, userData); // Safe to retry

2. Include All Required Properties

Always include all properties entity should have:

Good:

{
  "@id": "ex:user-123",
  "@type": "schema:Person",
  "schema:name": "Alice",
  "schema:email": "alice@example.org",
  "ex:status": "active"
}

Bad (incomplete):

{
  "@id": "ex:user-123",
  "schema:name": "Alice"
}

3. Use Consistent Schema

Define entity schema and always include all fields:

function createUserTransaction(user) {
  return {
    "@id": `ex:user-${user.id}`,
    "@type": "schema:Person",
    "schema:name": user.name || null,
    "schema:email": user.email || null,
    "schema:telephone": user.phone || null,
    "ex:status": user.status || "active"
  };
}

4. Document Upsert Usage

Comment when using upsert for idempotent sync:

// Upsert for idempotent sync with external API
await fetch('http://localhost:8090/v1/fluree/upsert?ledger=users:main', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(userPayload),
});

5. Test Idempotency

Verify operations are truly idempotent:

const result1 = await upsert(data);
const result2 = await upsert(data);
// Should produce same final state

6. Monitor Performance

Track metrics for replace operations:

  • Flakes retracted
  • Flakes asserted
  • Commit time
  • Indexing lag

7. Consider Alternatives

For partial updates, use WHERE/DELETE/INSERT:

{
  "where": [{ "@id": "ex:alice", "schema:age": "?oldAge" }],
  "delete": [{ "@id": "ex:alice", "schema:age": "?oldAge" }],
  "insert": [{ "@id": "ex:alice", "schema:age": 31 }]
}

Comparison Table

FeatureDefault ModeReplace Mode
BehaviorAdditiveReplaces each named predicate’s values
Predicates not in the payloadPreservedPreserved
IdempotentNoYes, with one exception (see Idempotency)
Partial updatesYes (with WHERE/DELETE/INSERT)Per predicate
Use caseAdding dataSynchronization
Retry safetyRequires careSafe by default, with the same exception
PerformanceFewer operationsMore operations