- Step 7 of 17 in the agent-building course
Build Persistent Agent Memory with Updates, Expiry and Deletion

A user tells your agent, "Keep it short." Saving that is easy. The trouble starts later. The user changes their mind. Another user asks the same question and shouldn't get the first user's setting. An old process that's been running for a while tries to save the value it read an hour ago, on top of the new one.
A memory store needs a clear rule for each of those cases.
This lesson builds one small store for text preferences. It saves, reads, updates, expires and deletes records in SQLite. It runs thirteen local checks, including two reads from brand-new Python processes, which shows the data really lives on disk and not just in the running program's memory. No model or network connection is involved.
Download the complete memory store and fixtures.
1. Run the fixture sequence
Use Python 3.10 or newer with its sqlite3 module, which comes with standard Python. Save the file in a new folder and run:
python3 memory_lesson.py
Use python if that's your Python 3 command. The script creates a temporary database, runs the checks and deletes the temporary folder. It doesn't leave a store behind in your home folder.
The September 17, 2026 run passed all thirteen checks. Its output shows two reads from separate Python processes. One confirms an updated value and the other confirms a deletion. The original test process keeps running the whole time, so this isn't a test of losing power or a server failing over.
The test clock uses small numbers like 100 and 200, so you can test expiry without waiting. In a real app, pass in the current time in Unix seconds from a clock you trust.
2. Decide what this memory means
This store holds preferences, such as the writing tone a user asked for. Each record has:
| Field | Purpose |
|---|---|
| Owner and key | Isolate one user's preference from another's |
| Value | A text value of 1–500 characters |
| Source | explicit_user or model_inference |
| Version | An ID with no meaning of its own, replaced on every write |
| Expiry | The time after which reads return no value |
| Updated time | When this value was written |
A preference is a different thing from a workflow checkpoint or a retrieved document. A checkpoint saves where a workflow got to, so it can pick up again. A retrieved passage is source material the agent answers from.
LangGraph's persistence documentation covers checkpoints for its workflows. The preference store here doesn't resume a workflow or stop an outside action from happening twice.
The source field lets the app apply one simple rule. A preference the user stated, which hasn't expired, can't be overwritten by something the model guessed. Recording a source doesn't prove it's true, though. Your own trusted code has to decide which source label a write gets.
3. Save and read a value
The downloaded MemoryStore takes a database path and an owner. Its read method puts that owner into the query:
row = db.execute(
"SELECT key,value,source,version,expires_at,updated_at "
"FROM memory WHERE owner=? AND key=? AND expires_at>?",
(self.owner, key, now),
).fetchone()
The values go in as SQL parameters, the ? placeholders, instead of being pasted into the query text. Python's SQLite documentation explains parameters, databases saved to disk, and transactions.
To experiment without touching anything else, import the class from a second file next to the download:
from pathlib import Path
from tempfile import TemporaryDirectory
from memory_lesson import MemoryStore
with TemporaryDirectory() as folder:
store = MemoryStore(Path(folder) / "memory.sqlite", "alice")
first = store.put(
"tone", "concise",
source="explicit_user", expires_at=200, now=100,
)
print(store.get("tone", now=110)["value"])
It should print concise. A store for owner bob can't read Alice's row through get. Bob can save his own value under the same key.
These owner names show how queries are kept to one user. They aren't a login system, and they don't protect against someone who can open the database file directly. In a real service, create the store with the identity your app has confirmed, never with an owner name the model picked.
4. Update only the version you read
The first write returns a version, an ID with no meaning of its own. Pass it back when you change the value:
updated = store.put(
"tone", "detailed",
source="explicit_user", expires_at=200, now=110,
expected_version=first["version"],
)
Put this call inside the same temporary-folder block as the example above. It returns the new value and a different version.
Under the hood, the store starts a transaction, reads the current row, compares versions and writes the new value. Now say a caller still has the first version after the update. It gets Conflict("stale_version"). It has to read the current value again before deciding whether to change anything.
The test also tries to replace the user's stated value with one marked source="model_inference". That's rejected even with the right version. Matching the version stops outdated edits. It doesn't override the rule about where a value came from.
5. Delete, expire and recreate
Deleting also needs the version you mean to remove:
deleted = store.delete("tone", expected_version=updated["version"])
assert deleted is True
assert store.get("tone", now=120) is None
The test then starts a brand-new Python process to read that key. It gets null, which confirms the row is really gone, not just hidden from the first process.
Deleting the row in the app isn't the same as securely wiping backups, logs or old data left on disk. Those need their own rules for how long things are kept.
Expiry is checked on every read. A value that expires at 200 is visible at 199 and gone at 200. purge_expired(now) actually removes expired rows for that store's owner. Until they're purged, expired rows still have versions. So purge them before you recreate a missing preference without passing a version.
A recreated row gets a new version. An old delete request can't remove it by showing a version from before the deletion. If versions always reset to 1, the store couldn't tell the old row from the new one.
6. Inspect the thirteen checks
The recorded run checks that:
- A stored value can be read.
- Another owner cannot read that row through the store.
- Updating changes both the value and version.
- Different owners retain different values under the same key.
- A stale update is rejected.
- An inference cannot replace an unexpired explicit preference.
- A fresh process reads the updated value.
- Deletion succeeds with the current version.
- A fresh process confirms deletion.
- A value disappears exactly at its expiry boundary.
- Expired data can be purged for its owner.
- Recreating a record gives it a new version.
- An old delete cannot remove the recreated record.
To read from a fresh process, the script starts itself again with a special internal argument. That new process opens its own database connection. It doesn't get a copy of anything from the first process's memory.
7. Add one retention case
In fixtures(), after Bob's expired row is purged, add a new preference that Alice stated herself. Give it an expiry later than the test clock's current time. Call bob.purge_expired again and check that Alice's preference is still there. This shows the owner boundary holds during cleanup, not just during reads.
Then try writing a value with expires_at equal to now. The store should reject it with ValueError before saving anything. Keep that separate from Conflict, which means the version or the source didn't match.
Before you put a remembered value into a model prompt, decide whether that key matters for the current task and is still OK to use. A successful lookup only means the record passed this store's rules. It doesn't mean something the model once guessed is true.
You should end up with the thirteen-check output, the small save, update and delete experiment, and the extra retention checks. Next, bounded planning tracks which steps depend on which, kept separate from remembered preferences.



