# Build Persistent Agent Memory with Updates, Expiry and Deletion

> Run a SQLite preference store with owner scoping, versioned updates, provenance, expiry and deletion, then verify it from fresh Python processes.

- Source: https://www.zarifautomates.com/blog/how-to-build-ai-agent-with-persistent-memory
- Published: 2026-09-17
- Updated: 2026-09-19
- Pillar: Agents & AI Engineering
- Tags: agent-course, persistent-memory, practical-guide
- Author: Zarif

---

Remembering a preference is easy until the user changes it, another user asks the same question, or an old process tries to save a stale value. A useful memory store needs explicit behavior for those cases.

This lesson builds one small store for text preferences. It saves, reads, updates, expires and deletes records in SQLite. Thirteen local checks include two reads from fresh Python processes, so persistence is demonstrated beyond an in-memory object. No model or network connection is involved.

[Download the complete memory store and fixtures](/downloads/agent-course/memory_lesson.py).

## 1. Run the fixture sequence

Use Python 3.10 or newer with its `sqlite3` module available. Save the file in a new directory and run:

```bash
python3 memory_lesson.py
```

Use `python` if that is your Python 3 command. The script creates a temporary database, runs the sequence and removes the temporary directory. It does not create a permanent store in your home directory.

The September 17, 2026 run passed all thirteen checks. Its output identifies two separate Python subprocess reads: one confirms an updated value, and the other confirms deletion. The original test process remains running. This is not a power-loss or distributed-failover test.

The fixture clock uses small integer values such as 100 and 200, so expiry can be tested without waiting. In an application, supply your trusted current time in Unix seconds.

## 2. Decide what this memory means

This store holds preferences such as a requested writing tone. 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 opaque identifier replaced on every write |
| Expiry | The time after which reads return no value |
| Updated time | When this value was written |

A preference is different from a workflow checkpoint or a retrieved document. A checkpoint records execution state. A retrieved passage represents source evidence. [LangGraph's persistence documentation](https://docs.langchain.com/oss/python/langgraph/persistence) describes checkpointed graph state. The preference store here does not resume a graph or prevent duplicate external actions.

The source field lets the application apply a simple rule: an unexpired explicit preference cannot be overwritten by a model inference. Recording a source is not proof that it is truthful. Trusted application code must decide which source label a write deserves.

## 3. Save and read a value

The downloaded `MemoryStore` takes a database path and an owner. Its read method includes that owner in the query:

```python
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()
```

Values are bound through SQL parameters. Python's [SQLite documentation](https://docs.python.org/3/library/sqlite3.html) explains parameter binding, disk-backed databases and transaction handling.

For an isolated experiment, import the class from a second file beside the download:

```python
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"])
```

The expected printed value is `concise`. A store for owner `bob` cannot retrieve Alice's row through `get`. Bob can create a different value under the same key.

These fixture owner strings demonstrate query scoping. They are not a login system or protection against someone who can open the database file directly. In a service, create the store using identity established by the application, not an owner name chosen by the model.

## 4. Update only the version you read

The first write returns an opaque `version`. Pass it back when changing the value:

```python
updated = store.put(
    "tone", "detailed",
    source="explicit_user", expires_at=200, now=110,
    expected_version=first["version"],
)
```

Place this call inside the same temporary-directory block in the preceding example. It returns the new value and a different version.

The implementation starts a transaction, reads the current row, compares its version and writes the replacement. If a caller still supplies the first version after the update, it receives `Conflict("stale_version")`. It must read current state before deciding whether another change is appropriate.

The fixture also tries to replace the current explicit value with `source="model_inference"`. It is rejected even with the correct version. Version agreement prevents stale edits. It does not override the provenance rule.

## 5. Delete, expire and recreate

Deletion also requires the version you intend to remove:

```python
deleted = store.delete("tone", expected_version=updated["version"])
assert deleted is True
assert store.get("tone", now=120) is None
```

The default fixture then launches a fresh Python process to read that key. It receives `null`, confirming the row is absent across process boundaries. Deleting the application row is not a claim that backups, logs or old storage pages have been securely erased. Those need their own retention policy.

Expiry is checked on every read. A value expiring at 200 is visible at 199 and absent at 200. `purge_expired(now)` physically removes expired rows for that store's owner. Expired rows still have versions until purged, so purge them before recreating a missing preference without an expected version.

A recreated row receives a new opaque version. An old delete request cannot remove it by presenting a version from before deletion. Using a version that always reset to `1` would make that distinction harder to enforce.

## 6. Inspect the thirteen checks

The recorded sequence verifies:

1. A stored value can be read.
2. Another owner cannot read that row through the store.
3. Updating changes both the value and version.
4. Different owners retain different values under the same key.
5. A stale update is rejected.
6. An inference cannot replace an unexpired explicit preference.
7. A fresh process reads the updated value.
8. Deletion succeeds with the current version.
9. A fresh process confirms deletion.
10. A value disappears exactly at its expiry boundary.
11. Expired data can be purged for its owner.
12. Recreating a record gives it a new version.
13. An old delete cannot remove the recreated record.

The subprocess helper starts the same script with an internal fixture-read argument. The child opens its own database connection. It does not receive a copy of the parent's Python objects.

## 7. Add one retention case

In `fixtures()`, add a new explicit preference for Alice after Bob's expired row is purged. Give it an expiry after the test clock's current value. Call `bob.purge_expired` again and verify that Alice's preference still exists. This makes the owner boundary visible during cleanup as well as reads.

Then try writing a value with `expires_at` equal to `now`. The store should reject it with `ValueError` before saving a row. Keep that failure distinct from `Conflict`, which indicates a version or provenance disagreement.

Before using a remembered value in a model prompt, decide whether that key is relevant to the current task and still appropriate to use. A successful database lookup only establishes that the record passes this store's rules. It does not establish that a stored inference is true.

Your deliverable is the thirteen-case output, the small save/update/delete experiment and the additional retention checks. Continue to [bounded planning](/blog/how-to-build-ai-agent-that-plans-and-executes-tasks) to track execution dependencies separately from remembered preferences.

## Related Guides

- [Agent Design Patterns: Run Four Control Flows and Their Failures](/blog/ai-agent-design-patterns-production-systems)
- [Choose an Agent Starter: Inspect Three Repositories and Test One](/blog/best-ai-agent-template-libraries-and-starters)
- [Agent Evaluation Tools: Compare Five Options on One Ticket Task](/blog/best-ai-agent-testing-and-evaluation-tools)
- [Amazon AI Updates: Bedrock and Alexa Changes](/blog/amazon-ai-updates-bedrock-alexa)

## Continue the course

Lesson 7 of 17.

Previous lesson: [Build a Small RAG Pipeline and Measure What It Misses](https://www.zarifautomates.com/blog/how-to-build-ai-agent-with-rag.md).

Next lesson: [Plan and Execute: Build a Bounded Ticket Workflow](https://www.zarifautomates.com/blog/how-to-build-ai-agent-that-plans-and-executes-tasks.md).

[Browse available lessons](https://www.zarifautomates.com/blog/pillar/agents-and-ai-engineering#agent-course-heading).
