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.

ESP32 only: requires the `btree` module

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

Quick Start

python

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

python

# 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

python

# 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

python

# 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

python

# Delete one record
db.delete(key)

# Purge entire database
db.delete(purge=True)

Batch Operations

python

# 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:

python

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:

python

# 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:

python

# 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:

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:

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.

python

db = MicroTetherDB(
    in_memory=True,
    max_records=500,   # bounds RAM; oldest records evicted first
)
`btree_pagesize` has a hard floor of 512

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):

python

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

File-backed storage is a buffer, not a durable store

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.

Flash wear: keep sustained file-backed write rates low

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
  • survive a reboot.

  • Buffer in RAM and write with put_batch() — a batch
  • of 60 costs one flush, not 60.

  • Do not call client.db_put() in a per-sample loop with
  • 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:

  1. 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's offline_max_records parameter so a long outage can't fill flash.
  2. Client database (client_db.db, in-memory by default): your application's local data store, accessed via client.db_put(), client.db_get(), etc.
python

# 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:

python

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:

python

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:

python

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