mem0's update() drops custom metadata. The fix has a catch.
In mem0ai’s Node SDK, versions 3.0.3 through 3.0.8, update(id, text) re-embeds the new text and then rewrites the stored metadata from scratch. Custom fields such as a memory type or an active flag disappear. None of this reached production. Upstream fixed it in 3.0.9, released on 2026-06-17. The backend at Kaizen Health pins 3.0.3, so its edit path calls a different method that does not drop fields. The fix also changes what that workaround does.
Everything below was checked against the published npm packages on 2026-07-19, using a stub in place of the database and synthetic rows. Nothing ran against real data.
Why the metadata matters
We use mem0’s open-source Node SDK to store memories extracted from chats and documents, with Supabase as the vector store. Each memory carries a JSON metadata object, and we filter on it: memory type, source, clinical status, an active flag. It also records which extraction run produced the memory.
When a user edits a memory’s text, the backend re-classifies the edit and writes it back. That write is the update call.
How update() loses the fields
Three functions are involved. These excerpts are from 3.0.3, trimmed to the relevant lines.
The public method embeds the text and passes it down without any metadata:
async update(memoryId, data) {
const embedding = await this.embedder.embed(data);
await this.updateMemory(memoryId, data, { [data]: embedding });
updateMemory takes metadata = {} as its fourth parameter, so here it is empty. It builds the new payload from that and a few fields copied from the old one:
const newMetadata = {
...metadata, // {} when called from update()
data,
hash: md5(data),
createdAt: existingMemory.payload.createdAt,
updatedAt: new Date().toISOString(),
...(existingMemory.payload.user_id && { user_id: existingMemory.payload.user_id }),
// agent_id and run_id are carried over the same way
};
await this.vectorStore.update(memoryId, embedding, newMetadata);
The Supabase store then writes that payload into the metadata column:
async update(vectorId, vector, payload) {
await this.client.from(this.tableName).update({
embedding: vector,
metadata: { ...payload, updated_at: new Date().toISOString() },
}).eq("id", vectorId);
}
A SQL UPDATE sets the column to the value it is given. Nothing merges, so the old JSON is gone. The store behaves normally. The loss happens one layer up, where updateMemory decides what the new payload contains: it carries over createdAt and the user, agent and run IDs, and nothing else.
Reproducing it
The harness below borrows the SDK’s own method bodies and gives them a recording stand-in for the Supabase client, so it exercises the real code paths without an embedding provider or a database. It is not a supported way to use the SDK, and it depends on internal method names.
import { Memory, SupabaseDB } from "mem0ai/oss";
// A recording stand-in for the Supabase client. An UPDATE replaces the column.
let row = {
id: "m1",
embedding: [0],
metadata: {
data: "old text",
createdAt: "2026-01-01T00:00:00.000Z",
user_id: "u1",
memory_type: "medication",
is_active: true,
extraction_batch_id: "batch-1",
source: "document",
},
};
const client = {
from: () => ({
select: () => ({ eq: () => ({ maybeSingle: async () => ({ data: row, error: null }) }) }),
update: (patch) => ({ eq: async () => { row = { ...row, ...patch }; return { error: null }; } }),
}),
};
const store = Object.assign(Object.create(SupabaseDB.prototype), {
client,
tableName: "memories",
embeddingColumnName: "embedding",
metadataColumnName: "metadata",
initialize: async () => {},
});
// Borrow the SDK's own method bodies and stub everything else.
const sdk = {
vectorStore: store,
embedder: { embed: async () => [0.1] },
db: { addHistory: async () => {} },
_ensureInitialized: async () => {},
_captureEvent: async () => {},
_displayFirstRunNotice: async () => {},
_sessionFiltersFromPayload: () => ({}),
_removeMemoryFromEntityStore: async () => {},
_linkEntitiesForMemory: async () => {},
updateMemory: Memory.prototype.updateMemory,
};
await Memory.prototype.update.call(sdk, "m1", "new text");
console.log(Object.keys(row.metadata).sort().join(", "));
We ran it against four versions. The row starts with eight metadata fields.
| mem0ai | Fields lost by update(id, text) |
|---|---|
| 3.0.3 | memory_type, is_active, extraction_batch_id, source |
| 3.0.8 | memory_type, is_active, extraction_batch_id, source |
| 3.0.9 | none |
| 3.2.0 | none |
The four fields that survive on every version are data, hash, createdAt and user_id. Versions before 3.0.3 were not tested.
What upstream changed
The v3.0.9 release notes list it under Bug Fixes as “Preserve custom metadata fields during update()”. They add that user-defined keys “were previously dropped on update; the existing payload is now spread before applying the new data” (#5480).
The v3.1.0 notes add a text option to update() and deprecate data. By 3.2.0, update() also accepts { text, metadata }, so the private method is no longer the only way to pass metadata.
Our workaround
Because the backend pins 3.0.3, the edit path skips the public method and calls updateMemory(memoryId, text, {}, metadata) directly. The SDK’s type definitions declare updateMemory as private, in 3.0.3 and in 3.2.0. Our backend is JavaScript, so the keyword does not stop the call.
We build a new object from the re-classified edit, plus an allowlist of provenance fields copied from the old memory:
const metadata = {
...editedFact.metadata, // fresh classification of the edited text
...currentProvenance, // allowlisted fields copied from the old memory
original_source: currentOriginalSource,
source: "user_edit",
edited_by_user: true,
edit_count: (Number(currentMemory.metadata?.edit_count) || 0) + 1,
last_edited_at: lastEditedAt,
};
await this.memory.updateMemory(memoryId, editedFact.text, {}, metadata);
Two safeguards cover the dependency on a private method. The edit path returns a 503 if updateMemory is missing, and a test fails in CI before that can happen:
test("mem0 private update guard remains available", () => {
assert.equal(typeof Memory.prototype.updateMemory, "function");
});
package.json allows ^3.0.3, which includes 3.2.0. Only package-lock.json keeps the install at 3.0.3.
The catch: replace became merge
On 3.0.3, updateMemory replaces the stored metadata with the object it receives. From 3.0.9 it spreads the old payload first and applies the new metadata on top. Our call passes a new object either way, so the difference is easy to miss. We tested it with a memory that has memory_type: "medication", clinical_status: "active" and end_date: "2026-03-01", then called updateMemory with only { memory_type: "condition" }:
| mem0ai | memory_type | clinical_status | end_date |
|---|---|---|---|
| 3.0.3 | condition | gone | gone |
| 3.0.9 | condition | active | 2026-03-01 |
| 3.2.0 | condition | active | 2026-03-01 |
Same call, same arguments. On 3.0.3 the fields the new payload did not mention are gone. On 3.0.9 and later the re-typed memory keeps its old clinical_status and end_date.
Our existence check passes on 3.2.0, because the method is still there with the same four parameters. Only its behavior changed, so a test that asks whether the method exists cannot catch it. A behavioral test can: seed a row, call updateMemory with partial metadata, read the row back, and assert which keys remain. It fails on whichever side of 3.0.9 it was not written for.
Still open
- What should happen to keys a re-classification omits, before we upgrade past 3.0.8. An
end_datethat no longer applies should go, and a provenance field should stay. - Whether 3.0.0 through 3.0.2 have the bug. They were not tested.
- The guard test still checks existence only.
If you depend on an SDK’s private or undocumented behavior
- Pin the version in a lockfile and write down why.
- Test the behavior you rely on, not the existence of the method.
- Read the release notes and diff the function before bumping. The v3.0.9 note describes the change in one sentence, which was enough to predict the flip.
- Find out what a partial update means in your SDK, replace or merge, and assert it in a test.