zstd Dictionaries — Training, Binding, Reads & Limits
Technical reference for per-bucket zstd dictionaries, an extension of
zstd Storage Compression. It covers
when a dictionary helps, how fs.zstd.dict samples, trains, and decides whether
to bind, the registry and binding format, which writes use a dictionary, how
reads find it, background retraining, and the limits to plan around.
Why dictionaries?
zstd learns the redundancy in whatever it is compressing, and each object is compressed on its own. A 3 KB JSON event repeats the same keys as thousands of other events in the bucket, but its compressor has never seen them, so every object pays to describe its own structure from scratch.
A dictionary is trained once on samples of a bucket’s content and handed to the encoder before each object. Structure shared across the bucket — field names, timestamps, enum values, CSV headers — then costs almost nothing per object.
- Pays off: many small objects with shared structure — JSON and NDJSON events, logs, CSV exports, metrics, API payloads, configuration files.
- Gains little: large objects, which already give zstd plenty of their own content to learn from, and content that is not compressed at all (JPEG, MP4, ZIP, and other already-compressed formats).
- Measure it on your data:
fs.zstd.dictcompares the new dictionary against what writes use today on samples it held back from training, and binds nothing unless the dictionary wins. - It costs CPU: tens of microseconds per read and more per write. See Performance before binding a write-heavy bucket.
On a test bucket of 500 synthetic JSON event and CSV order files (about 3 KB each), new objects stored 17–29% smaller than plain zstd:
| Object | Plain size | zstd, no dictionary | zstd + dictionary |
|---|---|---|---|
new-events.json |
3,264 B | 938 B | 666 B (.json dictionary) |
new-orders.csv |
2,481 B | 831 B | 690 B (.csv dictionary) |
new-notes.txt |
2,600 B | 762 B | 543 B (bucket default) |
Your ratio depends on how much structure your objects share; use the
fs.zstd.dict dry run to see it before binding anything.
How It Works
- Opt-in per bucket. Nothing changes until you bind a bucket with
fs.zstd.dict -apply. Unbound buckets, and writes outside the buckets directory, keep using plain zstd. - Per extension. Besides the bucket’s default dictionary, every file extension with enough samples of its own gets a specialized dictionary, so a bucket mixing JSON and CSV does not settle for a compromise between them.
- Self-describing data. Each stored zstd frame names the dictionary id it was encoded with. Readers need only that id, so reads work however the object is reached: S3, the filer, a mount, a volume server directly, or a backup.
- Immutable ids. A dictionary id always names the same bytes. A retrain writes a new id and leaves the old dictionary in place for the objects that use it.
Requirements
- Writers must be writing zstd. A binding never turns zstd on by itself.
Dictionaries are used only by processes running with
-compression.method=zstd(the default) and a license that permits zstd. A gzip or unlicensed writer ignores the binding. - Every reader must understand dictionary frames before you bind the first
bucket: volume servers (Go and Rust), filers, S3 gateways, mounts, and
filer.backup/filer.replicateprocesses. A binary from before dictionary support reads plain zstd but cannot decode dictionary frames, and open source SeaweedFS cannot read zstd at all. - Filer read JWT. If filer reads require a JWT (
jwt.filer_signing.read.keyinsecurity.toml), volume servers, S3 gateways, and mounts need the same key to fetch dictionaries from the filers.
Training with fs.zstd.dict
Run it from weed shell. It is a dry run unless you pass -apply.
# preview: sample, train, and measure, but write nothing
fs.zstd.dict -bucket events
# train, register, and bind
fs.zstd.dict -bucket events -apply
# show the binding
fs.zstd.dict -bucket events -show
# stop new writes from using a dictionary (existing objects keep reading)
fs.zstd.dict -bucket events -unbind -apply
A first -apply on the test bucket from Why dictionaries?:
> fs.zstd.dict -bucket events -apply
bucket events: sampled 715174 bytes in 256 samples from 500 files scanned; skipped 0 encrypted, 0 already compressed, 0 changed while sampling, 0 unreadable
bucket events: trained dict 2053701242 (114800 bytes) on 205 samples, generation 1
bucket events: 51 held-out samples compress to 44977 bytes with no dictionary, 34732 bytes with dict 2053701242 (22.8% smaller)
bucket events .csv: trained dict 3717109736 (114778 bytes) on 89 samples, generation 1
bucket events .csv: 22 held-out samples compress to 20109 bytes with no dictionary, 16722 bytes with dict 3717109736 (16.8% smaller)
bucket events .json: trained dict 1076099693 (114804 bytes) on 116 samples, generation 1
bucket events .json: 29 held-out samples compress to 25245 bytes with no dictionary, 17136 bytes with dict 1076099693 (32.1% smaller)
bucket events bound to /etc/zstd/dicts/2053701242.zdict
bucket events .csv bound to /etc/zstd/dicts/3717109736.zdict
bucket events .json bound to /etc/zstd/dicts/1076099693.zdict
> fs.zstd.dict -bucket events -show
bucket events: dict /etc/zstd/dicts/2053701242.zdict (generation 1)
bucket events .csv: dict /etc/zstd/dicts/3717109736.zdict (generation 1)
bucket events .json: dict /etc/zstd/dicts/1076099693.zdict (generation 1)
Sampling is random and ids are freshly allocated, so a dry run and the
-apply that follows report slightly different numbers and different ids.
Flags
| Flag | Default | Description |
|---|---|---|
-bucket |
— | Bucket name to train for (required). |
-bucketsDir |
/buckets |
Filer directory the buckets live under. |
-samples |
256 |
Maximum objects sampled. |
-sampleSize |
65536 |
Bytes read from the start of each sampled object (64 KiB). |
-minImprovement |
5 |
Bind only when held-out samples compress at least this many percent smaller than with the current dictionary (or no dictionary). |
-force |
false |
Bind even when the dictionary misses -minImprovement, or when too few samples leave none to hold out. |
-id |
derived | Pin the default dictionary’s id (32768 or higher). Normally left unset. |
-show |
false |
Print the bucket’s binding and exit. |
-unbind |
false |
Remove the binding. The dictionaries stay registered for existing objects. |
-apply |
false |
Write the dictionaries and the binding. Without it, nothing is written. |
How samples are chosen
- Spread across the bucket. The listing stops after 16 files per requested sample (4,096 at the defaults). Candidates are reservoir-sampled from that whole listing and shuffled, so a run is not limited to the first names of a time-ordered bucket, and each run draws a different sample set.
- Plaintext only. Content is read through the filer over HTTP, so stored
compression is undone and training sees the same bytes the write path
encodes. Only the first
-sampleSizebytes of each object are read. - Skipped, and counted in the
sampled …line:- encrypted objects: filer-ciphered chunks (
-encryptVolumeDataor a path’s cipher option) and S3 SSE-C, SSE-KMS, and SSE-S3 objects; - very large files stored behind a chunk manifest;
- content that is already compressed, such as uploaded
.gzor.zstfiles; - objects shorter than 8 bytes;
- objects replaced or removed between listing and fetch.
- encrypted objects: filer-ciphered chunks (
- Logical names. Versioned S3 objects train under their object key’s extension. In-progress multipart parts feed only the default dictionary.
Held-out measurement and adoption
Every fifth sample is held out. The dictionary trains on the other four fifths,
then the held-out samples are compressed twice — with the new dictionary and
with what writes use today (the bound dictionary, or no dictionary for a first
binding). The new dictionary is bound only when it is at least
-minImprovement percent smaller.
- Training needs at least 8 samples and 128 KiB of training content. Below that the run reports why and trains nothing.
- Measuring needs enough samples to spare a fifth and still meet those minimums
(at least 9 samples). A dictionary that could not be measured is not bound,
not even as a first binding, unless you pass
-force. -forcebinds the dictionary whatever it measures. Use it only when you know the samples are representative.
Per-extension dictionaries
Samples are also grouped by file extension: the lowercased suffix after the
last dot, 1–15 letters or digits (.json, .csv, .log, .parquet). Names
without a usable suffix count toward the default dictionary only.
- Each extension with at least 8 samples gets its own dictionary, measured against what those objects use today: their extension dictionary, else the bucket default.
- The default dictionary trains on all samples and serves objects whose extension has no dictionary of its own.
- An extension that cannot train or cannot be measured is skipped and keeps its current binding. Extension generations advance independently of the default.
- With the default 256 samples, only extensions that make up a good share of
the bucket qualify. Raise
-samplesfor buckets with many extensions.
Retraining
Running fs.zstd.dict -apply again retrains. Each adopted dictionary gets a new
generation and a fresh id; the earlier ones stay registered, so objects encoded
with them keep decoding. On a bucket whose content has not changed, the bound
dictionaries usually win and nothing is rebound:
> fs.zstd.dict -bucket events
bucket events: sampled 719471 bytes in 256 samples from 503 files scanned; skipped 0 encrypted, 0 already compressed, 0 changed while sampling, 0 unreadable
bucket events: trained dict 768774105 (114804 bytes) on 205 samples, generation 2
bucket events: 51 held-out samples compress to 30970 bytes with dict 2053701242, 34152 bytes with dict 768774105 (-10.3% smaller)
bucket events: improvement is below -minImprovement 5.0%; keeping the current dictionary (-force binds anyway)
...
bucket events: nothing to rebind
The binding is published only if no other trainer or unbind changed it while
the run was sampling. Otherwise -apply fails with “the dictionary binding of
bucket … changed while training” and you can rerun it.
Registry and Binding
| Item | Where | Contents |
|---|---|---|
| Registry | /etc/zstd/dicts/<id>.zdict |
One filer file per dictionary (~112 KiB), shared by the filers of a filer group and fetched over filer HTTP. |
| Binding | seaweedfs.zstd.dict extended attribute on /buckets/<name> |
JSON naming the default dictionary and any per-extension ones. |
The binding from the example above:
{
"id": 2053701242,
"path": "/etc/zstd/dicts/2053701242.zdict",
"generation": 1,
"extensions": {
".csv": {"id": 3717109736, "path": "/etc/zstd/dicts/3717109736.zdict", "generation": 1},
".json": {"id": 1076099693, "path": "/etc/zstd/dicts/1076099693.zdict", "generation": 1}
}
}
- Ids are in zstd’s user range (32768 and up), derived from the bucket, extension, generation, and a random token, and never reuse an id the registry already holds.
- Never delete or overwrite a registry file. Objects encoded with a
dictionary cannot be read without it. Removing one on a test cluster made its
objects fail with 503 (
zstd dictionary unavailable) from the volume server and 500 from the filer, while objects using other dictionaries, or none, kept reading. Only the exact dictionary bytes can decode those objects. - No garbage collection. Unbinding keeps the dictionaries, and every adopted retrain adds one registry file per dictionary. Each is about 112 KiB.
- Back it up with the data. Treat
/etc/zstd/dictsas part of the bucket data: back it up and restore it together with the volumes.
Writes
| Write path | Uses the bucket’s dictionary |
|---|---|
S3 PutObject, multipart UploadPart, browser POST uploads |
✓ |
| Filer HTTP uploads under the buckets directory, including TUS resumable uploads | ✓ |
Writers that upload straight to volume servers: weed mount, weed filer.copy, weed upload |
– |
| Anything outside the buckets directory | – |
- Chosen per object. The dictionary bound to the object’s extension is used, else the bucket default. Versioned S3 writes go by the object key, not the internal version file name. A bucket that has only extension dictionaries writes other objects without one.
- Same rules as plain zstd. Only content that is worth compressing is compressed, the compressed copy is kept only when it is smaller, and every replica stores identical bytes.
- S3 objects are compressed by Content-Type. An S3 upload is compressed —
with or without a dictionary — only when its
Content-Typeis a text type (text/*), an XML or script type,application/octet-stream, or absent. Objects uploaded asapplication/json,application/x-ndjson, orbinary/octet-streamare stored uncompressed, so neither zstd nor a dictionary applies to them. Check what your clients send:aws s3 cp, for example, labels.jsonfilesapplication/json. - Cached. Each writer re-reads a bucket’s binding at most every 30 seconds, so a bind, rebind, or unbind reaches new uploads within that window. Dictionary bytes are validated once and cached.
- Fails open. If the binding or dictionary cannot be read, or the dictionary is damaged, uploads continue without a dictionary and the writer logs a warning at most once a minute per bucket. Writes never fail because of a dictionary.
Reads
Every reader takes the dictionary id from the frame header and resolves it without any bucket context:
| Reader | Dictionary source |
|---|---|
| Filer | Its own store. |
| Volume server (Go and Rust) | Lists the cluster’s filers from the master and fetches the registry file from all of them over HTTP. |
S3 gateway, weed mount, weed shell, filer.backup, filer.replicate |
The filers they are configured with, over HTTP. |
- Cached for good. A dictionary that every reachable filer returned identically is cached for the life of the process, since an id never changes its bytes. A miss is retried after 30 seconds, and one lookup takes at most 5 seconds.
- Fails closed. Filer groups keep separate registries. If two filers return
different bytes for the same id, the reader refuses to decode instead of
guessing. A frame whose dictionary cannot be found, or that does not decode
with it, is never served as content: the volume server answers
503
zstd dictionary unavailable, so clients retry instead of storing garbage. - Clients outside SeaweedFS get plain content. Volume servers hand a stored
dictionary frame through as
Content-Encoding: zstdonly to SeaweedFS components that sendx-seaweed-zstd-dict-accept: 1; the response names the dictionary inx-seaweed-zstd-dict-id. Browsers,curl --compressed, and any other client asking forAccept-Encoding: zstdreceive decoded bytes.
Performance
A dictionary costs CPU mainly on writes: each dictionary encode first loads the dictionary into the encoder’s match tables, a mostly fixed cost per object. Reads reuse decoders that already hold the dictionary, so they cost about the same as plain zstd.
- Reads: S3 GETs of 0.8–3.6 KB objects took at most 10 µs longer than plain zstd, and 29 µs longer at 16 KB, with throughput within 5%. They were as fast as gzip or faster.
- Writes at the default level: S3 PUTs took 170–320 µs longer and, with the gateway CPU-bound, completed 41–50% fewer per second.
- Writes at
-compression.level=1: 65–90 µs longer and 16–21% fewer per second, with dictionary output as small as at the default level. - First read of a dictionary in a fresh process fetches it from the filers once; later reads use the cached copy.
Test setup
- AMD Ryzen 7 5700G (8 cores, 16 threads), 62 GiB RAM, NVMe, Linux; an
enterprise build running as one
weed server -s3with the master, volume server, filer, and S3 gateway in one process, and the load generator on the same host. - Synthetic JSON log events in four sizes (about 0.8, 3.6, 16, and 64 KB).
Dictionaries were trained on 512 other events of about 2.8 KB, by
fs.zstd.dictfor the S3 runs. - S3 uploads were sent as
text/plain, so both buckets compress them (see Writes for the Content-Type rule). - Two S3 buckets receive identical objects: one bound to the dictionary, one not. For reads, a third bucket holds the same objects compressed with gzip. Throughput runs rotate the bucket order between rounds; single-client latency runs alternate buckets request by request. Figures are medians.
- Writes were measured on a build from before decoders were reused, reads and decoding on a build that reuses them.
- The host was shared with other work (load average 2–15), so treat the numbers as relative, not as capacity figures for your hardware.
Codec cost per object
One core, -compression.level at its default, median of five runs. “Stored”
is the compressed size as a share of the original. Decoding was measured in a
separate run.
| Object | Encode: zstd → + dictionary | Decode: zstd → + dictionary | Stored: zstd → + dictionary |
|---|---|---|---|
| ~0.8 KB | 12.8 → 56.1 µs | 4.5 → 4.5 µs | 49.9% → 24.0% |
| ~3.6 KB | 30.3 → 75.9 µs | 9.0 → 11.1 µs | 27.5% → 19.5% |
| ~16 KB | 106 → 179 µs | 22.2 → 31.9 µs | 19.8% → 18.1% |
| ~64 KB | 386 → 508 µs | 67.0 → 107 µs | 17.9% → 17.5% |
For comparison, gzip (the open source codec) encodes these objects in 13.7, 24.6, 55.7, and 177 µs; in the decode run it decoded them in 13.3, 20.9, 53.3, and 181 µs.
The added encode cost grows slowly with object size (+43 µs for a 0.8 KB object, +122 µs for 64 KB), while the saving shrinks quickly: at 64 KB the dictionary stores 2% less for 32% more encode time. Decoding with a dictionary costs nothing extra at 0.8 KB and 2–10 µs more at 3.6–16 KB. Bind buckets of small objects; buckets of large objects gain little.
Many writers at once
At the default level, each dictionary encode first resets up to 1.25 MiB of match tables from the dictionary. With many writers in parallel, those copies compete for CPU caches and memory bandwidth. With 16 goroutines encoding or decoding 3.6 KB objects:
| Operation | zstd | + dictionary |
|---|---|---|
| Encode, default level | 3.3 µs per object (~300,000/s) | 75 µs per object (~13,000/s) |
Encode, -compression.level=1 |
1.7 µs per object (~590,000/s) | 4.5 µs per object (~220,000/s) |
| Decode | 1.6 µs per object (~630,000/s) | 2.1 µs per object (~480,000/s) |
Level 1 uses a single 256 KiB table, so dictionary encoding stays cheap under concurrency. On this data, level 1 with the dictionary also stored slightly smaller output than the default level with the dictionary (19.0% vs 19.5% at 3.6 KB). Without a dictionary, level 1 stores slightly larger output than the default (28.2% vs 27.5%).
S3 end to end
PUT, 16 clients — requests per second:
| Object | Default level: no dictionary → dictionary | Level 1: no dictionary → dictionary |
|---|---|---|
| ~0.8 KB | 12,524 → 6,248 (−50%) | 12,207 → 10,297 (−16%) |
| ~3.6 KB | 10,584 → 6,225 (−41%) | 11,687 → 9,278 (−21%) |
| ~16 KB | 8,494 → 4,972 (−41%) | 9,725 → 8,040 (−17%) |
PUT, one client — median latency:
| Object | Default level | Level 1 |
|---|---|---|
| ~0.8 KB | 0.94 → 1.10 ms (+168 µs) | 0.94 → 1.01 ms (+65 µs) |
| ~3.6 KB | 1.03 → 1.21 ms (+183 µs) | 1.25 → 1.34 ms (+82 µs) |
| ~16 KB | 2.96 → 3.28 ms (+320 µs) | 1.71 → 1.80 ms (+89 µs) |
The 16-client PUT runs kept the host’s CPU close to saturated, so the throughput drop is the dictionary’s CPU cost showing directly. A gateway with spare CPU sees the latency increase instead.
A CPU profile of the default-level PUT run shows where write time goes: dictionary encoding in the S3 gateway took 31% of all CPU, against 6% for plain zstd encoding, mostly in resetting the encoder’s tables. The volume server also decodes each uploaded chunk once to check its MD5. In these runs that decode still loaded the dictionary for every chunk; it now reuses a decoder that holds it.
GET (the level does not affect reads), three server sessions of five rounds each:
| Object | 16 clients, requests per second | One client, median latency |
|---|---|---|
| ~0.8 KB | 20,601 → 20,497 (−0.5%) | 0 µs |
| ~3.6 KB | 20,733 → 21,064 (+1.6%) | +9 µs |
| ~16 KB | 18,343 → 17,506 (−4.6%) | +29 µs |
gzip, read in the same rounds, served 19,840, 19,386, and 16,486 requests per second, and its single-client median latency was 3, 4, and 21 µs higher than the dictionary’s. Differences of a few percent are within this host’s run-to-run noise. Before decoders were reused, every read parsed the dictionary again: GETs took 35–70 µs longer than plain zstd and served 12–16% fewer requests per second.
First read. A freshly started weed s3 gateway fetches a dictionary from
the filers on its first read of an object that uses it. On localhost that
first GET took about 0.3 ms longer than the first GET of a plain object
(median of five restarts; the spread between runs was larger than the
difference). Across a network, expect one round trip plus about 112 KiB.
Recommendations
- Read-heavy buckets of small objects are the best fit: reads cost about the same as plain zstd, and objects of 1–4 KB stored 30–50% smaller than with plain zstd in these tests.
- Write-heavy buckets: run the writers (S3 gateways, filers) with
-compression.level=1. It cut the dictionary’s write cost by more than half in these tests without losing compression. The level is per process and applies to every write that process makes. - Large objects: above about 16 KB the dictionary saves little and still costs its full per-object write overhead, so leave such buckets unbound.
Background Retraining
The optional zstd_dict_retrain plugin job (Retrain Bucket Zstd
Dictionaries in the Admin UI) keeps bound buckets’ dictionaries current as
their content drifts. Each job runs the same sampling, training, and held-out
measurement as fs.zstd.dict -apply, never with -force, and writes nothing
unless the new dictionary is measurably better.
- Disabled by default. Enable it in the Admin UI.
- Only bound buckets. It retrains buckets that already have a binding and
never binds a new one; use
fs.zstd.dictfor the first binding. - Rotates. Each detection proposes one job per bound bucket, up to 16, and rotates through the rest on later detections.
- Requires an enterprise license; detection is skipped without one.
| Setting | Default | Description |
|---|---|---|
| Detection interval | 1440 min (daily) | How often bound buckets are proposed for retraining. |
| Max jobs per detection | 16 | Buckets retrained per detection. |
| Global / per-worker concurrency | 1 / 1 | One retrain at a time. |
| Job timeout / retries | 3600 s / 1 | A retry waits 600 s. |
Samples (sample_count) |
256 | Objects sampled per bucket (9–4096). |
Sample size (sample_size_kb) |
64 KiB | Bytes read from the start of each object (1–1024 KiB). |
Minimum improvement (min_improvement_percent) |
5% | How much smaller held-out samples must compress before the new dictionary replaces the bound one. |
The job runs on any weed worker serving the default job types, which
includes the default -jobType=all:
weed worker -admin=admin.example.com:23646
# or only this job
weed worker -admin=admin.example.com:23646 -jobType=zstd_dict_retrain
Security
A trained dictionary embeds up to 112 KiB of sampled content verbatim. The
registry file is not encrypted, can be read by anyone with filer read access to
/etc/zstd/dicts, and outlives the objects it was sampled from. For that
reason encrypted objects are never sampled. Before binding a bucket that holds
sensitive plaintext, check who can read /etc on your filers.
Rollout and Rollback
- Upgrade every reader — volume servers, filers, S3 gateways, mounts, and backup or replication processes — to a release with dictionary support.
- Run
fs.zstd.dict -bucket <name>as a dry run and check the measured improvement. - Bind with
-apply. New uploads use the dictionary within 30 seconds. For a write-heavy bucket, consider running its writers with-compression.level=1first (see Performance). - Optionally enable the
zstd_dict_retrainjob.
To roll back, fs.zstd.dict -bucket <name> -unbind -apply stops new writes from
using a dictionary. Objects already written with one still need their registry
files and dictionary-aware readers, so do not downgrade below dictionary support
once any bucket has been bound.
Limits
| Limit | Value |
|---|---|
| Dictionaries per bucket | One default plus one per extension |
| Dictionary size | ~112 KiB (a registry file over 4 MiB is rejected) |
| Dictionary ids | 32768 – 4294967295 |
| Minimum to train | 8 samples and 128 KiB of training content |
Minimum to measure (and bind without -force) |
9 samples, with 128 KiB left after holding out a fifth |
| Files listed per run | 16 × -samples |
| Extension | 1–15 letters or digits after the last dot, case-insensitive |
| Binding refresh on writers | 30 seconds |
| Reader miss retry / lookup timeout | 30 seconds / 5 seconds |