Polars expression plugin for translating DNA/RNA to protein, bit-for-bit compatible with BioPython's Seq.translate()
Project description
polars-seq
Translate DNA/RNA to protein inside Polars, at Rust speed, with exactly the semantics of
BioPython's Seq.translate().
import polars as pl
import polars_seq # noqa: F401 -- importing registers the .seq namespace
df = pl.DataFrame({"dna": ["ATGAAATTTTAA", "ATGGGCCCCTGA"]})
df.with_columns(protein=pl.col("dna").seq.translate())
# ┌──────────────┬─────────┐
# │ dna ┆ protein │
# ╞══════════════╪═════════╡
# │ ATGAAATTTTAA ┆ MKF* │
# │ ATGGGCCCCTGA ┆ MGP* │
# └──────────────┴─────────┘
It is a native Polars expression plugin (Rust, via pyo3-polars), not a Python UDF: it runs
inside the query engine, parallelises across threads, holds no GIL, and composes with the lazy
optimiser and the streaming engine.
Install and build (uv)
This is a compiled plugin, so building it needs a Rust toolchain. Nothing but polars is
needed at runtime.
1. Prerequisites
uv — if you don't have it:
curl -LsSf https://astral.sh/uv/install.sh | sh
Rust — rustup gives you cargo and rustc. The stable channel is pinned in
rust-toolchain.toml, so rustup will fetch the right one automatically:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source "$HOME/.cargo/env" # or restart your shell
cargo --version # should print something
You also need a C linker (cc). On Debian/Ubuntu: sudo apt install build-essential.
2. Build and install
git clone <this repo>
cd polars_seq
uv sync # creates .venv on Python 3.14, compiles the Rust extension, installs everything
uv sync reads .python-version (3.14) and pyproject.toml, downloads the interpreter if you
don't have it, builds the crate through the maturin backend, and installs polars-seq into
.venv along with the dev dependencies (pytest, biopython).
The first build compiles all of polars's Rust dependencies and takes a few minutes. Later
builds are incremental and take seconds.
Check it works:
uv run python -c "
import polars as pl, polars_seq
print(pl.DataFrame({'dna': ['ATGAAATTTTAA']}).with_columns(p=pl.col('dna').seq.translate()))
"
Run the tests (this is also the BioPython parity check):
uv run pytest -q
3. Rebuilding after you change the Rust
This is the one thing that will bite you. uv sync caches the built wheel and does not
notice that you edited src/*.rs — you will keep importing the old binary and wonder why your
change did nothing. To actually rebuild, use maturin develop, which compiles in place:
uv run maturin develop --uv # debug build, fast to compile
uv run maturin develop --uv --release # optimised; use this for benchmarking
Or force uv to rebuild from scratch:
uv sync --reinstall-package polars-seq --no-cache
Pure-Python changes under python/polars_seq/ need no rebuild at all — the install is editable.
4. Regenerating the codon tables
src/codon_tables.rs is generated from BioPython and committed. You only need to regenerate it
if you bump BioPython or change the alphabet:
uv run python codegen/generate_tables.py # verifies 27 tables x 4913 codons, then writes
uv run maturin develop --uv # rebuild so the new tables are compiled in
5. Building a wheel to install elsewhere
uv build # -> dist/polars_seq-0.1.0-*.whl
uv pip install dist/*.whl # on any machine with Python >= 3.10; no Rust needed
Troubleshooting
Both VIRTUAL_ENV and CONDA_PREFIX are set — maturin refuses to guess which environment you
mean. If you have conda on your PATH (an active base env is enough), either conda deactivate
first, or just unset it for the one command:
env -u CONDA_PREFIX uv run maturin develop --uv
cargo: command not found — ~/.cargo/bin isn't on your PATH. source "$HOME/.cargo/env".
Your Rust change had no effect — see §3. uv sync served you a cached wheel; use
maturin develop.
Python version — requires ≥ 3.10; developed and tested on 3.14. The extension is built
against the stable ABI (abi3), so one wheel works across versions.
Usage
.seq.translate()
pl.col("dna").seq.translate(
table=1, # NCBI id, or name/alias: "Standard", "SGC0", "Vertebrate Mitochondrial"
stop_symbol="*",
to_stop=False, # stop at the first in-frame stop, excluding it
cds=False, # validate as a complete coding sequence
gap="-",
on_error="raise", # or "null" -- see below
)
Every argument means what it means in BioPython.
df.with_columns(
protein = pl.col("dna").seq.translate(),
orf = pl.col("dna").seq.translate(to_stop=True),
mito = pl.col("dna").seq.translate(table=2),
bacterial = pl.col("dna").seq.translate(table="Bacterial"),
)
cds=True validates the sequence as a complete CDS: it must start with a start codon (reported
as M whatever it actually encodes), have a length divisible by three, end with a stop codon
(dropped from the output), and contain no internal stop.
pl.col("dna").seq.translate(cds=True) # "TTGAAATAA" -> "MK" (TTG is a start codon)
Nulls pass through as nulls. A trailing partial codon is dropped, as in BioPython.
.seq.reverse_complement()
IUPAC-aware and case-preserving. Six-frame translation is then just:
df.select(
**{f"fwd{i}": pl.col("dna").str.slice(i).seq.translate() for i in range(3)},
**{f"rev{i}": pl.col("dna").seq.reverse_complement().str.slice(i).seq.translate()
for i in range(3)},
)
polars_seq.codon_tables()
All 27 NCBI genetic codes as a DataFrame — ids, names, aliases, and which are dual-coding.
Ambiguity codes are handled properly
Ambiguous IUPAC nucleotides resolve the way BioPython resolves them, which is more subtle than
"anything unclear becomes X":
| codon | → | why |
|---|---|---|
GGN |
G |
all four GGx codons are Gly, so there is no ambiguity to report |
TAR |
* |
R=A/G, and both TAA and TAG are stops |
TAN |
X |
expands to two stops and two Tyr — genuinely unresolvable |
RAT |
B |
GAT=Asp, AAT=Asn → B is the IUPAC code for "Asp or Asn" |
SAA |
Z |
CAA=Gln, GAA=Glu → Z = "Glu or Gln" |
RAT → B is the one that catches people out. An implementation that emits X for every
unresolvable codon looks correct until someone runs it on ambiguous data.
The X-codon quirk
While building this I ran into a genuinely surprising corner of Seq.translate(). It is worth
knowing about whether or not you use this library.
CTX translates to L. XXX is an error.
Both contain X. Here is why they differ.
BioPython keeps two different sets of nucleotide letters, and they disagree with each other:
- the expansion table, which says what each letter can stand for. It has 17 keys, and
Xis one of them — it expands toGATC, exactly likeN. - the validity check, which decides whether a codon it failed to translate is at least
made of legal characters. That set is
_ambiguous_dna_letters | _ambiguous_rna_letters— 16 letters, andXis not among them.
Translation consults the expansion table first, and falls back to the validity check only when the expansion fails. And the expansion fails in exactly one situation: when it runs into a stop codon. So:
CTX→ expands toCTA/CTC/CTG/CTT→ all four are Leucine, no stops → resolves toL. The validity check is never reached and theXcosts nothing.XXX→ expands to all 64 codons → amino acids and stops → the expansion gives up → now the validity check runs, sees theX, and rejects the codon:Codon 'XXX' is invalid.
The precise rule, which we verified exhaustively against BioPython (817 X-bearing codons × 27 tables, zero counterexamples):
An
X-bearing codon is accepted if and only if none of the codons it expands to is a stop. When accepted, it means exactly what theNspelling means.
So X is a perfect synonym for N — right up to the moment the expansion touches a stop codon,
where N degrades gracefully and X becomes a hard error:
| expansion | N spelling |
X spelling |
|---|---|---|
| one amino acid, no stops | CTN → L |
CTX → L |
| several amino acids, no stops | AAN → X |
AAX → X |
| amino acids and stops | TAN → X |
TAX → error |
| everything | NNN → X |
XXX → error |
Note the second row: an accepted X codon can come out as X — there X is an amino-acid
ambiguity code (Lys-or-Asn), not a nucleotide. What it can never be is a stop.
This is easy to get wrong in both directions: treat X as invalid everywhere and you break
CTX → L; treat it as a plain synonym for N and you wrongly accept TAX and XXX. I got it
wrong the first way, and only the differential fuzz caught it — on the sequence CTTCTX. Every
hand-written test I had passed. polars-seq now reproduces the real behaviour and re-sweeps all
817 X-bearing codons × 27 tables against BioPython on each test run.
This is BioPython's actual, current behaviour — 1.78 and 1.87 agree — so it is a quirk to match, not a bug to route around. It falls out of the two letter-sets having drifted apart.
Differences from BioPython
Two, both deliberate, because a DataFrame is not a single sequence.
1. on_error="null". BioPython raises on a malformed sequence, and so do we, by default. But
in a million-row frame, one bad sequence aborting the whole query is usually not what you want:
df.with_columns(protein=pl.col("dna").seq.translate(on_error="null"))
# invalid sequences become null; everything else still translates
The default, on_error="raise", reports the row index and the offending codon.
2. No per-row warnings. BioPython emits a BiopythonWarning for a trailing partial codon.
Warning once per row from inside a parallel Rust kernel is not viable, so we are quiet about it.
The behaviour is identical — the partial codon is dropped either way.
Dual-coding tables (27/28/31) do still warn, once, when the expression is built — and to_stop
with those tables is still rejected, exactly as BioPython rejects it.
Correctness
The parity claim is enforced, not asserted. uv run pytest runs:
- an exhaustive codon sweep — all 4913 codons × all 27 NCBI tables = 132,651 comparisons against BioPython, including which codons it refuses;
- differential fuzz — thousands of random sequences over ten alphabets (unambiguous, IUPAC, RNA, gapped, lower-case, invalid-character, …) crossed with the full option space, asserting we produce the same protein and fail on exactly the same inputs;
- golden tests for every documented rule and trap;
- Polars integration — nulls, empty frames, chunked and sliced Series, lazy, streaming,
group_by().agg().
The lookup tables are generated from BioPython (codegen/generate_tables.py) by calling the
real _translate_str on every codon, rather than by re-implementing its resolver in Rust — which
is exactly the sort of code that produces RAT → X bugs. BioPython is a build- and test-time
dependency only; it is not needed at runtime.
Performance
A Rust kernel with one array lookup per codon, no per-row allocation, parallel across chunks.
uv run python tools/validate.py reproduces the numbers on your own machine and data, and writes
a full row-by-row BioPython comparison to tmp/ so you can inspect the output rather than trust
it.
Layout
codegen/generate_tables.py BioPython -> Rust lookup tables (self-verifying)
src/translate.rs the kernel: framing, stops, gaps, cds rules
src/codon_tables.rs GENERATED -- do not edit
src/expressions.rs Polars expression entry points
python/polars_seq/ the .seq namespace and argument validation
tests/ golden, exhaustive, differential-fuzz, integration
tools/validate.py writes validation + benchmark artefacts to tmp/
Licence
MIT.
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 Distributions
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 polars_seq-0.1.0.tar.gz.
File metadata
- Download URL: polars_seq-0.1.0.tar.gz
- Upload date:
- Size: 73.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
29ae3316404d4e863adeda3b591d437b479d202bad170850cb171ae23fccac05
|
|
| MD5 |
3dfa6c5ce072fb7be9c835f81998583e
|
|
| BLAKE2b-256 |
f123fe6720bbc55de43f5510a6d80087bef5a94d9be7921fcd6a5e7a97d2d20c
|
Provenance
The following attestation bundles were made for polars_seq-0.1.0.tar.gz:
Publisher:
release.yml on drchristhorpe/polars-seq
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
polars_seq-0.1.0.tar.gz -
Subject digest:
29ae3316404d4e863adeda3b591d437b479d202bad170850cb171ae23fccac05 - Sigstore transparency entry: 2170693028
- Sigstore integration time:
-
Permalink:
drchristhorpe/polars-seq@e4b7136049d9ec57c6f1f4565e71e7e19284d17b -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/drchristhorpe
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@e4b7136049d9ec57c6f1f4565e71e7e19284d17b -
Trigger Event:
push
-
Statement type:
File details
Details for the file polars_seq-0.1.0-cp310-abi3-win_amd64.whl.
File metadata
- Download URL: polars_seq-0.1.0-cp310-abi3-win_amd64.whl
- Upload date:
- Size: 4.0 MB
- Tags: CPython 3.10+, Windows x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d6700bd1db53826ba69e0ab42b100912e2686fc3d0a3649a8c8cb70b16aa1c64
|
|
| MD5 |
420eb07d3fc327d77d31ec9067340d81
|
|
| BLAKE2b-256 |
b76c58cb24f550f87d29074ccbea51ed6acc2d60cefb0c53c581cc41472deddb
|
Provenance
The following attestation bundles were made for polars_seq-0.1.0-cp310-abi3-win_amd64.whl:
Publisher:
release.yml on drchristhorpe/polars-seq
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
polars_seq-0.1.0-cp310-abi3-win_amd64.whl -
Subject digest:
d6700bd1db53826ba69e0ab42b100912e2686fc3d0a3649a8c8cb70b16aa1c64 - Sigstore transparency entry: 2170694367
- Sigstore integration time:
-
Permalink:
drchristhorpe/polars-seq@e4b7136049d9ec57c6f1f4565e71e7e19284d17b -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/drchristhorpe
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@e4b7136049d9ec57c6f1f4565e71e7e19284d17b -
Trigger Event:
push
-
Statement type:
File details
Details for the file polars_seq-0.1.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.
File metadata
- Download URL: polars_seq-0.1.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- Upload date:
- Size: 3.6 MB
- Tags: CPython 3.10+, manylinux: glibc 2.17+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9bdd777eb23e46390afe508e38e51881efaeee8e6ab4d276211ab4690302f90d
|
|
| MD5 |
027c866fb42ab702e7669dcdf35e609f
|
|
| BLAKE2b-256 |
817a2bb3b9e998d3808a9dc759b99198550ca3da941127788eb3e3a8b39d5dcb
|
Provenance
The following attestation bundles were made for polars_seq-0.1.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl:
Publisher:
release.yml on drchristhorpe/polars-seq
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
polars_seq-0.1.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl -
Subject digest:
9bdd777eb23e46390afe508e38e51881efaeee8e6ab4d276211ab4690302f90d - Sigstore transparency entry: 2170694036
- Sigstore integration time:
-
Permalink:
drchristhorpe/polars-seq@e4b7136049d9ec57c6f1f4565e71e7e19284d17b -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/drchristhorpe
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@e4b7136049d9ec57c6f1f4565e71e7e19284d17b -
Trigger Event:
push
-
Statement type:
File details
Details for the file polars_seq-0.1.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.
File metadata
- Download URL: polars_seq-0.1.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
- Upload date:
- Size: 3.3 MB
- Tags: CPython 3.10+, manylinux: glibc 2.17+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
22c93f5c5f93186fbd3cccaf61ee4afeafa3cde5bc5187d79447f3f3b4c1d480
|
|
| MD5 |
d90f52946c02812c25ce18829279cde0
|
|
| BLAKE2b-256 |
e1a45578e9b66852c5b314af726cc2699c74b75fa51e183c71ab2349c7174a22
|
Provenance
The following attestation bundles were made for polars_seq-0.1.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl:
Publisher:
release.yml on drchristhorpe/polars-seq
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
polars_seq-0.1.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl -
Subject digest:
22c93f5c5f93186fbd3cccaf61ee4afeafa3cde5bc5187d79447f3f3b4c1d480 - Sigstore transparency entry: 2170693332
- Sigstore integration time:
-
Permalink:
drchristhorpe/polars-seq@e4b7136049d9ec57c6f1f4565e71e7e19284d17b -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/drchristhorpe
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@e4b7136049d9ec57c6f1f4565e71e7e19284d17b -
Trigger Event:
push
-
Statement type:
File details
Details for the file polars_seq-0.1.0-cp310-abi3-macosx_11_0_arm64.whl.
File metadata
- Download URL: polars_seq-0.1.0-cp310-abi3-macosx_11_0_arm64.whl
- Upload date:
- Size: 3.1 MB
- Tags: CPython 3.10+, macOS 11.0+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
75378e9e89d1243ebecd59b773343fc9a6cda1b6982ad20cdb73c7a5cdf9afd7
|
|
| MD5 |
d7fb9001549e85b5e8213fef13d7925a
|
|
| BLAKE2b-256 |
77b2222f08a9f961109cf86d673204d73f827da33bd576a33866c5278282abf4
|
Provenance
The following attestation bundles were made for polars_seq-0.1.0-cp310-abi3-macosx_11_0_arm64.whl:
Publisher:
release.yml on drchristhorpe/polars-seq
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
polars_seq-0.1.0-cp310-abi3-macosx_11_0_arm64.whl -
Subject digest:
75378e9e89d1243ebecd59b773343fc9a6cda1b6982ad20cdb73c7a5cdf9afd7 - Sigstore transparency entry: 2170693635
- Sigstore integration time:
-
Permalink:
drchristhorpe/polars-seq@e4b7136049d9ec57c6f1f4565e71e7e19284d17b -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/drchristhorpe
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@e4b7136049d9ec57c6f1f4565e71e7e19284d17b -
Trigger Event:
push
-
Statement type:
File details
Details for the file polars_seq-0.1.0-cp310-abi3-macosx_10_12_x86_64.whl.
File metadata
- Download URL: polars_seq-0.1.0-cp310-abi3-macosx_10_12_x86_64.whl
- Upload date:
- Size: 3.5 MB
- Tags: CPython 3.10+, macOS 10.12+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
03d3dfc0a1920c1c292963991a3e6b57feaa39f33379332a6bf9cd81c93c4aaf
|
|
| MD5 |
9ae8137394c8c7c9896788ec98e1a5f9
|
|
| BLAKE2b-256 |
b2ec2719c61507321f6e09baf32389f2c1861e9f3ee48c5a162a355d6fa0599c
|
Provenance
The following attestation bundles were made for polars_seq-0.1.0-cp310-abi3-macosx_10_12_x86_64.whl:
Publisher:
release.yml on drchristhorpe/polars-seq
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
polars_seq-0.1.0-cp310-abi3-macosx_10_12_x86_64.whl -
Subject digest:
03d3dfc0a1920c1c292963991a3e6b57feaa39f33379332a6bf9cd81c93c4aaf - Sigstore transparency entry: 2170694633
- Sigstore integration time:
-
Permalink:
drchristhorpe/polars-seq@e4b7136049d9ec57c6f1f4565e71e7e19284d17b -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/drchristhorpe
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@e4b7136049d9ec57c6f1f4565e71e7e19284d17b -
Trigger Event:
push
-
Statement type: