Steganodf hides a secret message inside a dataframe.
Give it a dataframe and a few bytes to hide, steganodf gives you back a dataframe that still looks and reads like the original but carries your secret message. Anyone with the password (or with none, if you did not set one) can read the message back out. Typical use: watermarking a dataset you are about to share, so that a leaked copy can be traced back to the recipient it was issued to.
Hiding data in a table is a trade-off between three properties, and no single scheme wins on all three at once:
- Capacity — how many bytes fit in the dataset.
- Robustness — does the message survive what happens to data
- Invisibility — how much the original data is distorted, and how easily an observer can tell that a watermark is there at all.
Steganodf ships four algorithms that sit at different points of that trade-off.
The web app runs the whole library in your browser, compiled to WebAssembly: nothing is uploaded, the dataframe never leaves your machine — https://dridk.github.io/steganodf/
With uv runs the command line in a throwaway environment, without installing anything:
uvx steganodf -hOtherwise install it the usual way:
pip install steganodfsteganodf --version
# Encoding
steganodf encode -m hello host.csv stegano.csv
steganodf encode -m hello host.parquet stegano.parquet
steganodf encode -m hello -p password host.parquet stegano.parquet
# Decoding
steganodf decode stegano.csv
steganodf decode stegano.csv -p password
# Choosing an algorithm, and the carrier column for bitvote / bitghost
steganodf encode -m hello -a bitvote -c price host.csv stegano.csv
steganodf decode -a bitvote -c price stegano.csv
# Decoding without knowing the algorithm: tries all four, names the one that matched
steganodf decode -a auto stegano.csvThe CLI reads and writes .csv and .parquet, and exposes --password, --column and
--algorithm. Tuning parameters such as bit_per_row or data_size are Python-only.
import steganodf
import polars as pl
df = pl.read_parquet("my_dataset.parquet")
# The payload is bytes, not str
watermarked = steganodf.encode(df, b"made by steganodf", password="secret")
# Extract your message from the watermarked dataframe
message = steganodf.decode(watermarked, password="secret")# A shuffle-resistant watermark
watermarked = steganodf.encode(df, b"made by steganodf", algorithm="bitvote")
shuffled = watermarked.sample(fraction=1.0, shuffle=True)
steganodf.decode(shuffled, algorithm="bitvote") # b'made by steganodf'For anything beyond the defaults, instantiate the class directly:
from steganodf.algorithms import BitPool, BitSync, BitVote, BitGhost
# 8 bits per row instead of 1: ~7x the capacity on a large dataframe
algorithm = BitPool(bit_per_row=8, password="secret")
algorithm.get_max_payload_size(df) # conservative estimate, in bytes
watermarked = algorithm.encode(df, b"a much longer message ...")If you receive a watermarked dataframe without being told which algorithm wrote it, pass
algorithm="auto".
steganodf.decode(df, algorithm="auto", password="secret") # b'made by steganodf'try_decode does the same thing but also tells you which algorithm matched:
steganodf.try_decode(df, password="secret")
# {'payload': b'made by steganodf', 'success': True, 'votes': 100000,
# 'margin_min': 469, 'algorithm': 'bitvote', 'tried': ['bitvote']}The three methods:
- permutation — the message lives in the order of the rows. Nothing is written to the data itself, so the dataset is bit-for-bit the same multiset of rows. The price is that any operation that re-orders the table erases the message.
- alteration — the message lives in the least significant bits of one numeric column. The values change, by one unit in the last place, and the row order becomes irrelevant.
- synthesis — the message lives in extra rows that steganodf fabricates and inserts. Existing values are never touched, but the dataset gains records that were not in it.
DETAIL.md covers each algorithm in turn — what it is built on, what it is good at, where it breaks — plus the threat model.
Measured on a 100 000-row, 4-column frame (Int64, Utf8, two Float64) with a 16-byte payload.
| Algorithm and settings | Method | Destroys original data | Max capacity (100k rows) | Tolerates cell edits | Tolerates row deletion | Survives sorting | Invisibility |
|---|---|---|---|---|---|---|---|
bitpool bit_per_row=1 |
permutation | no — rows are only reordered | 4.4 kB | 2 % | 2 % | ❌ | perfect — not one cell changed |
bitpool bit_per_row=8 |
permutation | no — rows are only reordered | 29 kB | 12 % | 20 % | ❌ | perfect — not one cell changed |
bitsync default max_drift (44 s decode) |
permutation | no — rows are only reordered | 2.1 kB | 1.5 % | 4 % | ❌ | perfect — not one cell changed |
bitsync max_drift=256 (5.7 s decode) |
permutation | no — rows are only reordered | 2.1 kB | 2 % | 3 % | ❌ | perfect — not one cell changed |
bitvote data_size=24 |
alteration | 1 ULP on one numeric column | 19 B | 45 % | 95 % | ✅ | very high — relative change of 2.2e-16 |
bitvote data_size=260 |
alteration | 1 ULP on one numeric column | 255 B | 15 % | 85 % | ✅ | very high — relative change of 2.2e-16 |
bitghost redundancy=8 |
synthesis | +168 fabricated rows | 250 B | 18 % | 50 % | ✅ | low — the fake rows are visible |
bitghost redundancy=32 |
synthesis | +672 fabricated rows | 250 B | 40 % | 80 % | ✅ | low — the fake rows are visible |
steganodf requires Python 3.11+ and is developed with uv:
uv sync --extra dev
uv run pytest --doctest-modules steganodf tests # or: make testSacha Schutz, Meganne Souprayen. Watermark tabular datasets with rows permutations and fountain code. TechRxiv. April 28, 2025. DOI: 10.36227/techrxiv.174585796.61215338/v1
@article{schutz2025steganodf,
title = {Watermark tabular datasets with rows permutations and fountain code},
author = {Schutz, Sacha and Souprayen, Meganne},
year = {2025},
month = {4},
journal = {TechRxiv},
doi = {10.36227/techrxiv.174585796.61215338/v1},
url = {https://www.techrxiv.org/doi/full/10.36227/techrxiv.174585796.61215338/v1}
}