Docs / Contact / sdks/micropython/microtetherdb
MicroTetherDB: Embedded Database
MicroTetherDB is a lightweight embedded database built for microcontrollers. It gives you persistent key-value storage with TTL expiration, tag-based querying, and batch operations, all designed to run within the RAM constraints of an ESP32.
MicroTetherDB is built on MicroPython's btree module, which is compiled into the firmware and cannot be installed later. In practice that makes it an ESP32-only feature.
Measured on hardware: btree is present on every ESP32 variant we've tested (C3, WROOM and WROVER) because the port turns MICROPY_PY_BTREE on for all builds. It is absent on every non-ESP32 board we've tested: the Raspberry Pi Pico W (rp2), the OpenMV AE3 (alif) and the OpenMV N6 (stm32). Those are three different ports, so treat "not an ESP32" as meaning "no local database" unless you have checked the specific board.
The SDK degrades cleanly rather than crashing: Client(client_db=True) on such a board prints a warning, leaves client.storage as None, and messages written with write_offline=True fall back to an in-RAM queue that still flushes on reconnect. They just don't survive a reboot.
Run import btree on your own board to check, or connect it to the device console, which probes for this and disables the database install tier when it's missing.
The Tendrl MicroPython Client uses MicroTetherDB internally for offline message storage and local device state. You can also use it directly in your application for any on-device data that needs to survive reboots or be queried locally.
Why Use It
- Built for microcontrollers: small memory footprint, configurable page sizes, and an optional record cap to keep storage bounded
- TTL expiration: data automatically expires and cleans up, no manual housekeeping
- Tag-based queries: tag your data and query it back without building indexes
- Field queries with operators:
$gt,$lt,$in,$contains, and more - Offline resilience: file-backed mode persists data across reboots on a best-effort basis (durability)
- Batch operations: insert or delete many records efficiently in a single call
Quick Start
from tendrl.lib.microtetherdb import MicroTetherDB
# In-memory (fast, lost on reboot)
db = MicroTetherDB(in_memory=True)
# File-backed (slower, usually survives reboot - see Durability below)
db = MicroTetherDB(filename="mydata.db", in_memory=False)
Store Data
# Store a reading
key = db.put({"temperature": 25.5, "location": "room-1"})
# Store with tags
key = db.put(
{"temperature": 25.5, "location": "room-1"},
tags=["sensor", "temperature"]
)
# Store with TTL (auto-expires after 1 hour)
key = db.put(
{"alert": "high_temp", "value": 45.0},
ttl=3600,
tags=["alert"]
)
Retrieve Data
# Get by key
data = db.get(key)
# {"temperature": 25.5, "location": "room-1", "_tags": ["sensor", "temperature"]}
# Returns None if not found or expired
data = db.get("nonexistent_key") # None
Query Data
# Find by exact field value
results = db.query({"location": "room-1"})
# Find by tag
results = db.query({"_tags": "temperature"})
# Comparison operators
results = db.query({"temperature": {"$gt": 30}})
# Combine conditions (AND)
results = db.query({
"location": "room-1",
"temperature": {"$gte": 20, "$lte": 30}
})
# Limit results
results = db.query({"_tags": "sensor", "$limit": 10})
Delete Data
# Delete one record
db.delete(key)
# Purge entire database
db.delete(purge=True)
Batch Operations
# Insert many at once
items = [
{"sensor": "temp", "value": 25.5},
{"sensor": "humidity", "value": 60},
{"sensor": "pressure", "value": 1013},
]
keys = db.put_batch(items, ttls=3600) # All expire in 1 hour
# Different TTL per item
keys = db.put_batch(items, ttls=[3600, 7200, 0]) # 0 = no expiry
# Delete many at once
count = db.delete_batch(keys)
Query Operators
| Operator | Example | Description |
|---|---|---|
| Direct value | {"name": "John"} |
Exact equality |
$eq |
{"age": {"$eq": 30}} |
Explicit equality |
$gt |
{"temp": {"$gt": 30}} |
Greater than |
$gte |
{"temp": {"$gte": 30}} |
Greater than or equal |
$lt |
{"temp": {"$lt": 10}} |
Less than |
$lte |
{"temp": {"$lte": 10}} |
Less than or equal |
$ne |
{"status": {"$ne": "off"}} |
Not equal |
$in |
{"type": {"$in": ["a", "b"]}} |
Value in list |
$exists |
{"field": {"$exists": true}} |
Field exists in document |
$contains |
{"_tags": {"$contains": "sensor"}} |
String/array contains value |
$limit |
{"$limit": 10} |
Limit number of results |
Nested Fields
Use dot notation to query nested objects:
db.put({"sensor": {"type": "dht22", "location": "outdoor"}})
results = db.query({"sensor.type": "dht22"})
results = db.query({"sensor.location": {"$ne": "indoor"}})
TTL (Time-To-Live)
Data with a TTL automatically expires and is cleaned up in the background:
# Expires in 60 seconds
db.put({"event": "motion_detected"}, ttl=60)
# Expires in 24 hours
db.put({"daily_report": {...}}, ttl=86400)
# No expiration (default)
db.put({"config": "permanent"})
# Manually trigger cleanup (normally automatic)
deleted_count = db.cleanup()
The TTL system uses a min-heap for efficient expiration checking: it doesn't scan all records, just checks the next-to-expire item. Cleanup runs automatically every 60 seconds by default.
Bounded Growth (Size Cap)
TTL bounds data by age. For unattended devices you often also want to bound it by count so the database can never grow without limit, for example if data is produced faster than it expires, or never expires at all. Set max_records:
# Keep at most 1000 records; oldest are evicted first when the cap is hit
db = MicroTetherDB(filename="readings.db", in_memory=False, max_records=1000)
# Unlimited (default); only do this when growth is otherwise bounded
db = MicroTetherDB(max_records=None)
Behavior:
- The cap is on record count, not bytes. Combined with the 8 KB per-record
- Eviction is oldest-first: keys are time-ordered, so the newest data is
- It works alongside TTL: TTL removes expired data, the cap enforces a hard
- Eviction happens lazily on insert (
put/put_batch), so there's no
limit it gives a predictable worst-case footprint.
always preserved. This makes it ideal for rolling buffers and offline queues.
ceiling regardless of age. Using both is the strongest guarantee against runaway storage.
background scanning cost when you're under the cap.
Configuration
| Parameter | Default | Description |
|---|---|---|
filename |
"microtether.db" |
Database file path |
in_memory |
True |
True for RAM-only, False for file-backed |
max_records |
None |
Hard cap on stored records. When set, the oldest records are evicted first once the cap is exceeded, bounding RAM/flash growth. None = unlimited (see Bounded Growth). |
btree_cachesize |
32 |
BTree page cache, in bytes (not pages). Leave it alone; see below. |
btree_pagesize |
1024 in-memory, 4096 file-backed |
BTree page size in bytes. Valid range is 512–65536; anything smaller raises OSError: EINVAL. |
ttl_check_interval |
60 |
Seconds between automatic TTL cleanup |
Tuning
The defaults are already tuned, and they key off the backend, not the board. Page size is the only parameter that materially affects throughput, and what it should be depends on where the pages live:
- File-backed → large pages (4096). Pages live in flash, so bigger pages cost
- In-memory → small pages (1024). Pages are charged to the heap, so page
no RAM and make batched writes dramatically faster: on an ESP32-S3, a 100-item batch delete went from 39.2 s at 512 bytes to 5.5 s at 4096, about 7×.
waste is real memory. At 4096, small records cost 3.3× more RAM (81 → 270 bytes per record).
Because of that second point, a board with more RAM should not use larger in-memory pages, since it would waste more, not less. There is no board-size tuning to do here.
btree_cachesize is not worth touching: raising it from 32 to 4096 changed file delete time by 1.3%, which is measurement noise.
Constrained boards (ESP32 without PSRAM): reach for max_records, not page size: capping the record count is what actually bounds RAM.
db = MicroTetherDB(
in_memory=True,
max_records=500, # bounds RAM; oldest records evicted first
)
Values below 512 are rejected by MicroPython's btree module with OSError: EINVAL; they do not silently fall back. An earlier version of this page suggested btree_pagesize=256 for constrained boards; that example does not work and has been removed.
Persistent storage (file-backed):
db = MicroTetherDB(
filename="/data/readings.db",
in_memory=False,
# btree_pagesize omitted on purpose: file-backed already defaults to 4096,
# which is the fast setting. Passing 1024 here would make batched writes
# ~7x slower.
max_records=5000 # Bound flash usage
)
In-Memory vs File-Backed
| In-Memory | File-Backed | |
|---|---|---|
| Speed | Fast | Slower (flash I/O) |
| Persistence | Lost on reboot | Survives reboot |
| RAM usage | Entire DB in RAM | Only cache pages |
| Best for | Caches, session data, temp state | Offline queue, config, logs |
Durability and flash wear
MicroPython's btree module (BerkeleyDB 1.85) has no journaling and no crash recovery. A power cut, brownout, or reset partway through a write can leave the database file inconsistent or unopenable.
MicroTetherDB detects a damaged file when it opens it, moves it aside, and starts a fresh one, so a device recovers on its own instead of failing forever — but the records in the damaged file are lost. The same applies to the client's offline message queue: messages buffered during an outage can be lost if the device loses power before they are replayed.
Treat file-backed storage as a best-effort buffer that usually survives a reboot. Anything you cannot afford to lose should be delivered to the platform and acknowledged rather than left sitting on the device. On-device storage carries no durability guarantee.
Every flush rewrites whole btree pages, not the bytes you changed. A file-backed flush costs at least two 4096-byte pages (the metadata page plus one leaf), so storing a 100-byte record can cost 8 KB of flash writes — roughly 80× write amplification.
The flush timer makes this easy to underestimate: file-backed mode flushes every 10 writes or every 5 seconds, whichever comes first, so even a slow writer pays a full 8 KB flush per interval.
Rough budget for a 4 MB ESP32 (~2 MB filesystem, 4 KB sectors rated ~100,000 erase cycles ≈ 200 GB of writes, halved here for filesystem overhead):
Sustained put() rate |
Flash written | Time to exhaust |
|---|---|---|
| 1 per minute | ~12 MB/day | decades |
| 1 per 10 seconds | ~71 MB/day | ~4 years |
| 1 per second | ~142 MB/day | ~2 years |
| 10 per second | ~708 MB/day | ~5 months |
These are order-of-magnitude estimates, not measurements. To stay well clear:
- Keep
in_memory=True(the default) for anything that does not need to - Buffer in RAM and write with
put_batch()— a batch - Do not call
client.db_put()in a per-sample loop with
survive a reboot.
of 60 costs one flush, not 60.
client_db_in_memory=False.
Nothing here is specific to Tendrl: it is how MicroPython's btree module (BerkeleyDB 1.85) writes to any block device. Note that max_records bounds the size of the file, not the volume of writes — a capped rolling buffer still rewrites pages on every insert and eviction.
How It Works with the Tendrl Client
The MicroPython Client creates two MicroTetherDB instances automatically:
- Offline storage (
tether.db, file-backed): queues messages when MQTT is disconnected, replays them on reconnect. Bounded by default (1000 records, oldest-first eviction) via the client'soffline_max_recordsparameter so a long outage can't fill flash. - Client database (
client_db.db, in-memory by default): your application's local data store, accessed viaclient.db_put(),client.db_get(), etc.
# These client methods use MicroTetherDB under the hood:
client.db_put({"temp": 25.5}, tags=["sensor"])
client.db_get(key)
client.db_query({"_tags": "sensor"})
client.db_delete(key)
You can also create standalone MicroTetherDB instances for your own data needs since it's not tied to the Tendrl Client.
Practical Examples
Sensor Reading Buffer
Buffer readings locally and query recent data without a network call:
db = MicroTetherDB(in_memory=True)
# Store readings with TTL
def store_reading(temp, humidity):
db.put(
{"temp": temp, "humidity": humidity},
ttl=3600, # Keep for 1 hour
tags=["reading"]
)
# Get recent high-temp readings
hot = db.query({"temp": {"$gt": 35}, "$limit": 20})
Offline Command Queue
Queue commands for execution even if the main loop is busy:
db = MicroTetherDB(filename="commands.db", in_memory=False)
# Receive and store command
def on_command(msg):
db.put(msg["data"], tags=["pending"])
# Process pending commands
pending = db.query({"_tags": "pending"})
for cmd in pending:
execute(cmd)
db.delete(cmd["_key"])
Device Configuration Store
Persist configuration across reboots:
db = MicroTetherDB(filename="config.db", in_memory=False)
# Save config
db.put({"interval": 30, "sensors": ["dht22", "bmp280"]}, key="app_config")
# Load config on boot
config = db.get("app_config")
if config:
interval = config["interval"]
Limits
- Maximum value size: 8KB per record (JSON-encoded)
- Maximum operation queue: 50 pending operations
- Record cap: Unlimited by default; set
max_recordsfor oldest-first eviction - Query method: Full table scan (no secondary indexes), best for small-to-medium datasets
- Key format: Auto-generated as
{timestamp}:{ttl}:{id}, or specify your own with thekeyparameter
Tendrl