CLI tool for backing up Grist documents as SQLite files
Project description
grist-backup
CLI tool to back up Grist documents as their
native SQLite (.grist) files — equivalent to the Download → Document
(with history) action in the Grist web UI.
Written in pure Python standard library: zero runtime dependencies.
Features
- Downloads the original SQLite
.gristfile (full edit history by default) - Multiple accounts in a single config file (e.g. work + personal)
- Explicit doc list or auto-discovery via the API (optionally scoped by workspace name)
- Auto-discovers document name, workspace and team via the Grist API
- Configurable subdirectory layout by
account/team/workspace xz(LZMA) compression by default —.gristfiles compress extremely well- Streaming download + on-the-fly compression (no double disk write)
- Atomic writes via temp file + rename
- Clear exit codes for scripting / cron jobs
- All errors logged to stderr
Installation
Requires Python 3.11+.
pip install grist-backup
From source (development)
git clone https://github.com/lmzr/grist-backup.git
cd grist-backup
uv sync
uv run grist-backup --help
Configuration
Create a TOML config file in one of (searched in order):
./grist-backup.toml(current directory)~/.config/grist-backup/config.toml~/.grist-backup.toml
Or override with --config /path/to/file.toml.
Generate an example config file with:
grist-backup --example-config > ~/.config/grist-backup/config.toml
Example:
backup_dir = "~/grist_backups"
subdirs = ["team"] # optional — default: ["team"]
[accounts.perso]
api_key = "your-api-key-here"
server = "https://docs.getgrist.com"
# Only `id` is required. Name is auto-fetched from the Grist API.
[[accounts.perso.docs]]
id = "abc123DEF456"
# Override the fetched name explicitly:
[[accounts.perso.docs]]
id = "xyz789UVW012"
name = "budget_override"
[accounts.work]
api_key = "another-api-key"
server = "https://grist.mycompany.com"
[[accounts.work.docs]]
id = "mno345PQR678"
Lock the file down — it contains API keys:
chmod 600 grist-backup.toml
Self-hosted Grist instances
The tool works with self-hosted Grist (grist-core). All API endpoints used are part of the core project. A few things to keep in mind:
server must point to the root URL — not to an org-specific path:
# Correct:
server = "https://grist.example.com"
# Wrong (will break /api/orgs discovery):
server = "https://grist.example.com/o/myorg"
On self-hosted multi-org instances (GRIST_SINGLE_ORG unset), Grist uses
URL path prefixes (/o/orgname/) to distinguish teams. The /api/orgs
endpoint lives at the root level and won't respond correctly if the server
URL already includes an org path.
On single-org instances (GRIST_SINGLE_ORG=myorg), the root URL is the
only option — no special handling needed.
Metadata auto-discovery
For each document, grist-backup calls GET /api/docs/{docId} to retrieve its
name, workspace and team — unless the name is already set in the config
and subdirs doesn't reference team or workspace (no API call needed
in that case).
If the metadata call fails (no network, bad key, deleted doc…), the tool
falls back silently: doc name defaults to the doc id, and any missing
team / workspace subdirectory level is dropped.
Subdirectory layout (subdirs)
Controls how backups are organized under backup_dir:
| Value | Result |
|---|---|
[] |
Flat — all files directly in backup_dir |
["team"] (default) |
One subdir per team |
["account"] |
One subdir per account config name |
["account", "team"] |
Nested: account → team |
["team", "workspace"] |
Nested: team → workspace |
["account", "team", "workspace"] |
Full nesting |
team and workspace come from the Grist API; account is the name you
defined in the config ([accounts.<name>]).
Auto-discovery of documents
Instead of listing each doc in the config, you can let the tool fetch the list from the Grist API. The selection is hierarchical: everything, or one or more teams (in full), or specific workspaces within a team.
# Everything
[accounts.everything]
api_key = "..."
server = "https://docs.getgrist.com"
discover = true
# Scoped — array of entries, each qualifies a team
[accounts.scoped]
api_key = "..."
server = "https://mycompany.getgrist.com"
discover = [
{ team = "MyCompany" }, # whole team
{ team = "PartnerOrg", workspaces = ["Finance", "Product"] }, # scoped
]
# Or equivalent array-of-tables (better for many entries):
[accounts.scoped2]
api_key = "..."
server = "https://mycompany.getgrist.com"
[[accounts.scoped2.discover]]
team = "MyCompany"
[[accounts.scoped2.discover]]
team = "PartnerOrg"
workspaces = ["Finance", "Product"]
If the same team is listed twice, once as "whole team" and once with workspaces, the whole-team entry wins and the scoped ones are dropped (logged at info level).
CLI flag --discover forces discovery at runtime (scope = everything),
taking precedence over any discover / docs setting:
grist-backup -a perso --discover
grist-backup --all --discover # back up everything for every account
Precedence per account:
config discover |
config docs |
CLI --discover |
Used |
|---|---|---|---|
absent / false |
non-empty | — | explicit docs |
absent / false |
empty | absent | error (exit 2) |
true / list |
— | — | discovery (with warning if docs also present) |
| — | — | present | discovery (scope = everything) |
Discovery enumerates teams via GET /api/orgs, then workspaces+docs via
GET /api/orgs/{orgId}/workspaces. Because those responses already contain
team and workspace names, per-doc metadata fetches are skipped during the
download phase.
If the discovery API call fails (network, auth…), the account is treated
as failed: exit code 2 with -a, or skipped with a warning under --all.
Getting an API key
In Grist: click your profile avatar (top right) → Profile Settings → API section → Create.
API keys inherit the user's permissions.
Usage
grist-backup (-a ACCOUNT | --all) [-d DOC_ID] [-o OUTPUT] [-c CONFIG]
[--no-history] [--no-compress] [--discover]
Exactly one of -a or --all is required.
| Flag | Description |
|---|---|
-a, --account |
Account name in config |
--all |
Back up every document of every account in config |
-d, --doc |
Document ID or config doc name (requires -a, overrides config docs list) |
-o, --output |
Output file or directory (overrides backup_dir) |
-c, --config |
Config file path (overrides default search) |
--no-history |
Exclude edit history from the backup |
--no-compress |
Disable xz/lzma compression |
--discover |
Fetch the doc list from the API (overrides config) |
--example-config |
Print an example config file to stdout and exit |
-V, --version |
Show version and exit |
Default filename: {doc_name}_{YYYY-MM-DD}_{HHMMSS}.grist.xz
(without compression: .grist).
The timestamp format can be customized in the config file with timestamp_format
(strftime syntax):
timestamp_format = "%Y%m%d" # → budget_20260420.grist.xz
Example tree with default subdirs = ["team"] and --all across two
accounts on different teams:
~/grist_backups/
├── MyCompany/
│ ├── budget_2026-04-16_081948.grist.xz
│ └── roadmap_2026-04-16_081948.grist.xz
└── Personal/
└── notes_2026-04-16_081948.grist.xz
With subdirs = ["account", "team"]:
~/grist_backups/
├── perso/Personal/notes_2026-04-16_081948.grist.xz
└── work/MyCompany/
├── budget_2026-04-16_081948.grist.xz
└── roadmap_2026-04-16_081948.grist.xz
Examples
Back up all documents for one account:
grist-backup -a perso
Back up every document of every account (ideal for cron):
grist-backup --all
Back up one specific document by ID, saved to a custom directory:
grist-backup -a work -d mno345PQR678 -o /mnt/nas/grist_backups/
Back up one document by its config name (the name field in [[accounts.<acct>.docs]]):
grist-backup -a perso -d budget_override
Back up uncompressed with a custom filename:
grist-backup -a perso -d abc123 --no-compress -o today_budget.grist
Without history (smaller file, no undo):
grist-backup -a perso --no-history
Exit codes
| Code | Meaning |
|---|---|
| 0 | All downloads succeeded |
| 1 | Partial failure (some succeeded, some failed) |
| 2 | Total failure or config/argument error |
Decompressing .grist.xz files
macOS (native Archive Utility, or CLI):
unxz backup.grist.xz # replaces file in place
# or
xz -d -k backup.grist.xz # keeps the .xz file
Installing xz if missing: brew install xz.
Linux: unxz / xz -d (usually pre-installed).
Windows: 7-Zip or PeaZip.
Python:
import lzma, shutil
with lzma.open("backup.grist.xz") as src, open("backup.grist","wb") as dst:
shutil.copyfileobj(src, dst)
The resulting .grist is a standard SQLite database — inspectable with
sqlite3 backup.grist ".tables" or any SQLite GUI.
Automation (cron)
Example daily backup of every account at 3 AM:
0 3 * * * cd $HOME && /usr/local/bin/grist-backup --all >> /var/log/grist_backup.log 2>&1
The tool always logs to stderr — the redirection above captures both channels.
Development
uv run ruff check src/
uv run ruff format src/
Building & Publishing
# Build the package (creates dist/*.tar.gz and dist/*.whl)
uvx --from build pyproject-build
# Check that the README renders correctly for PyPI
uvx twine check dist/*
# Upload to TestPyPI
uvx twine upload --repository testpypi dist/*
# Upload to PyPI (production)
uvx twine upload dist/*
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file grist_backup-1.0.0.tar.gz.
File metadata
- Download URL: grist_backup-1.0.0.tar.gz
- Upload date:
- Size: 29.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1d97946c8b640cc142aa47f344353a9412bd3a26d63eb1743a4f7e3a305456dd
|
|
| MD5 |
8ecc8a68f3bc91866f703423bd1f0851
|
|
| BLAKE2b-256 |
96d4a1174fcabd283ae193dc641068e2587cb7265f8d4c9db86fbd87999435eb
|
File details
Details for the file grist_backup-1.0.0-py3-none-any.whl.
File metadata
- Download URL: grist_backup-1.0.0-py3-none-any.whl
- Upload date:
- Size: 29.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a5677c24af2c17e4d186016dd1950cca31832c0a2dd8ba7d5026c08ed84e7ca5
|
|
| MD5 |
dffcd47d6e1f518140523194506af9a0
|
|
| BLAKE2b-256 |
4a8f7761c84fc5751f2e351f2b9140d9fddc66aaccb8287f0fd773aca7e9f695
|