A complete, spec-exact Rust implementation of EDTF — the Extended Date/Time Format, standardized as the profile in ISO 8601-2:2019 Annex A — covering conformance levels 0, 1 and 2 in full.
One zero-dependency core, compiled into every layer that needs it, so a date that is valid in your application is valid in your database — always:
| Crate | What it is |
|---|---|
edtf-core |
The implementation. #![no_std], zero runtime dependencies. Parsing, validation, level classification, calendar bounds, three-valued temporal relations, value enumeration, canonical formatting, positioned errors. Optional serde feature. |
edtf-normalize |
Deterministic prose-date → EDTF normalizer at the human input boundary: "1980s" → 198X, "circa 1920" → 1920~, "около 1920 г." → 1920~. Honest ambiguity ("12/04/1985" returns both readings, never a guess), every output valid canonical EDTF by construction. English and Russian pattern tables; no_std + alloc, no third-party dependencies (only edtf-core). |
edtf-calendars |
Proleptic Julian (Old Style) → Gregorian conversion at the ingest boundary: day precision converts exactly, year/month precision returns honest earliest/latest spans. #![no_std], no third-party dependencies (only edtf-core). |
edtf-wasm |
WebAssembly bindings for JavaScript: isValid, level, canonical, parse (JSON summary), relation, and normalize (prose → EDTF via edtf-normalize). |
edtf-postgres |
Postgres extension (via pgrx, Postgres 14–18): edtf_valid(), edtf_level(), edtf_canonical(), edtf_min(), edtf_max(), edtf_relation() as SQL functions. |
edtf-cli |
The edtf command-line tool: validate / canonical / level / info over arguments or stdin, plus relation (three-valued comparison of two expressions) and from-julian (Old Style → Gregorian EDTF). Installable anywhere via cargo install edtf-cli (or pin it with mise: "cargo:edtf-cli"). |
This replaces an EDTF layer that ran edtf.js inside Postgres under plv8. It worked, but plv8 compiles V8 from source — a database build took 40 minutes — and the standing cost was a JIT-ing JavaScript runtime, with its own heap, GC and threads, resident in every Postgres backend.
That constraint is what the design answers. A Postgres backend is fork()ed
per connection, unwinds errors through siglongjmp, and allocates from
transaction-scoped memory contexts; nothing carrying its own runtime belongs
inside one. So edtf-core is #![no_std] with zero runtime dependencies —
which is also precisely what lets the same code become a small WebAssembly
bundle and a static CLI binary. One implementation, three deployment shapes,
no divergence to reconcile. Installing the extension is now a tarball
download and CREATE EXTENSION.
The old engine's behaviour was not discarded, but it is an oracle rather
than an authority: tests/fixtures/legacy pins its verdicts as a
cross-check, and where they disagree with the Annex A reading in
docs/spec-notes.md, the spec wins and the divergence
gets a documented note.
- Level 0: calendar dates, reduced precision, date-times with UTC/shift, date intervals.
- Level 1: letter-prefixed (
Y…) and negative years, seasons 21–24, whole-expression?~%qualification, the level 1 unspecified-digit shapes (201X,2004-XX,1985-04-XX, …), open (..) and unknown (empty) interval ends, qualified interval endpoints. - Level 2: exponential years, significant digits (
S), sub-year codes 25–41, sets ({…}/[…]) with..ranges, group and individual qualification, unspecified digits anywhere,..-bounded interval endpoints. - Real calendar validation:
1985-02-30is rejected;2000-02-29is accepted and1900-02-29is not;1985-02-3Xis rejected because no February day starts with 3;X900-02-29is rejected because no year ending in 900 is a leap year. - Bounds: every expression maps to earliest/latest calendar days
(
156X→ 1560-01-01…1569-12-31), the primitive for range queries. - Relations: three-valued comparison under uncertainty — is
1985~before199X? Definitely; is198Xbefore1985? Possibly. Six coarsened Allen relations, each impossible / possible / definite, never over-asserting (semantics: D23 in the spec notes). - Value enumeration:
values()lazily yields the concrete calendar values an expression denotes —{1667,1668,1670..1672}expands exactly as ISO 8601-2 §6.4 does,156Xyields its ten years,1985-0X-31its five valid months,XXXX-XX-XXstreams ~3.65M days without allocating. Intervals and..-open set elements are honestlyUnenumerable(semantics: D24–D29 in the spec notes). - Canonical formatting:
Displayrenders the spec-preferred form (ISO 8601-2 §8.2.4) —?2004-?06-?11normalizes to2004-06-11?. - Strict profile boundaries: everything Annex A excludes is rejected —
basic format (
19850412), week/ordinal dates, durations, and the entire explicit-form designator system are not EDTF and do not parse.
Every grammar production and every judgment call is documented with ISO section citations in docs/spec-notes.md, including 29 resolved decisions (D1–D29) and one identified erratum in the ISO text itself (Annex A.6.3 Example 2, which contradicts its own normative clause).
use edtf_core::{Edtf, Bound};
assert!(edtf_core::is_valid("2004-06~-11")); // level 2, June ~2004, day 11
assert!(!edtf_core::is_valid("1985-02-30")); // no such day
let d = Edtf::parse("156X-12-25").unwrap();
assert_eq!(d.level(), 2);
assert!(d.has_unspecified());
let b = d.bounds(); // 1560-12-25 ..= 1569-12-25
use edtf_core::Relation;
let a = Edtf::parse("1985~").unwrap();
assert_eq!(a.relation(&Edtf::parse("199X").unwrap()).definite(),
Some(Relation::Before)); // ~ never moves boundsSQL, after CREATE EXTENSION edtf_postgres:
SELECT edtf_valid('1985-24'); -- true (winter 1985)
SELECT edtf_min('156X'), edtf_max('156X'); -- 1560-01-01, 1569-12-31
SELECT edtf_relation('1985~', '199X'); -- {definitely_before}
-- e.g. as a consistency rule: born must not be after died
-- CHECK (NOT ('definitely_after' = ANY(edtf_relation(born, died))))
SELECT daterange(edtf_min(production_date),
edtf_max(production_date), '[]') @> DATE '1965-06-15'
FROM artworks;Prebuilt, attested tarballs are attached to each edtf-postgres-v* release
from v1.1.0 onward, so installing needs no Rust toolchain and no
cargo-pgrx. Verify before extracting — the archive unpacks into / as
root, so checking the signature afterwards is a postmortem, not verification.
VERSION=1.1.0
PG=18 # 14, 15, 16, 17 or 18
ARCH=amd64 # amd64 or arm64 — dpkg's spelling, not uname's
FILE="edtf_postgres-${VERSION}-pg${PG}-linux-${ARCH}.tar.gz"
gh release download "edtf-postgres-v${VERSION}" --repo CarlAllenn/edtf \
--pattern "${FILE}" --pattern SHA256SUMS
gh attestation verify "${FILE}" --repo CarlAllenn/edtf \
--source-ref "refs/tags/v${VERSION}" \
--signer-workflow CarlAllenn/edtf/.github/workflows/publish.yml
sha256sum --check --ignore-missing SHA256SUMS
sudo tar -xzf "${FILE}" -C /Then, as any user with CREATE on the database — the extension is marked
trusted, so superuser is not required:
CREATE EXTENSION edtf_postgres;Upgrading a previously installed copy: extract the new tarball over the old
one, then ALTER EXTENSION edtf_postgres UPDATE;. Extracting without
running the update leaves the new library registered under the old version.
If you are running pg_upgrade, install the extension into the new
cluster before upgrading.
| Postgres | amd64 |
arm64 |
glibc floor |
|---|---|---|---|
| 14, 15, 16, 17, 18 | ✅ | ✅ | 2.36 |
Built inside postgres:<major>-bookworm against that image's pgdg
pg_config, and installed-and-exercised on both bookworm and trixie before
release. The tested set and the shipped set are the same set.
glibc 2.36 is a floor, not a target: Debian 12, Debian 13 and Ubuntu 24.04
are all fine, Ubuntu 22.04 (2.35) is not. musl/Alpine is not covered — a
different libc entirely. The tarball assumes the Debian pgdg layout
(/usr/lib/postgresql/<major>/lib, /usr/share/postgresql/<major>); a
Postgres built from source under a different prefix needs the files placed
by hand from pg_config --pkglibdir and --sharedir.
A major is dropped from the matrix as it reaches end of life; Postgres 14 reaches EOL in November 2026.
JavaScript, via the wasm package:
import { isValid, parse, normalize } from "edtf-wasm";
isValid("2004-06~-11"); // true
JSON.parse(parse("1985-04-12/.."));// { kind: "interval", earliest: "1985-04-12", latest: "infinity", … }
JSON.parse(normalize("circa 1920"));// { kind: "normalized", edtf: "1920~", … }Toolchain and every linter are pinned via mise with a checksum lockfile; git hooks (lefthook) and CI run the same gauntlet:
mise install # pinned Rust + all linters
task ci # fmt + clippy + cargo-deny + taplo + codespell + ec + tests
task wasm # build the WebAssembly artifact
task pg:test # Postgres extension tests (needs `cargo pgrx init` once)The test suite includes a 63-case conformance corpus derived from every
Annex A example plus adversarial cases, every example from the Library of
Congress EDTF specification page as an interop cross-check
(tests/fixtures/loc), a second interop corpus harvested from the edtf.js
and python-edtf test suites (tests/fixtures/interop — shared verdicts
enforced, implementation extensions pinned as must-rejects, every
divergence a documented decision), ~200 spec-derived assertions, bounds
verification, a canonical-form round-trip property over all fixtures,
model-side property tests (proptest generates random valid values as
structured data and checks round-trip identity, level stability and bounds
ordering), and a deterministic fuzz harness (hundreds of thousands of
hostile inputs per run; the parser must never panic, and everything it
accepts must round-trip). On top of that, coverage-guided fuzzing
(fuzz/, cargo-fuzz) runs nightly in CI against the same never-panic and
round-trip properties.
Parse errors carry the byte offset of the problem:
1985-02-30 → invalid EDTF at offset 8: day is out of range for the month.
Criterion benchmarks live in crates/edtf-core/benches/core.rs (task bench).
Representative numbers, Apple M1 Pro, rustc 1.97.1, --release:
| Input | parse | canonicalize | bounds |
|---|---|---|---|
1985-04-12 |
187 ns | 283 ns | 190 ns |
1985-04-12T23:20:30+04:30 |
150 ns | 458 ns | 209 ns |
2004-06~ |
106 ns | 203 ns | 139 ns |
?2004-06-~11 |
192 ns | 295 ns | 199 ns |
156X-12-25 |
185 ns | 284 ns | 173 ns |
Y-17E7S3 |
74 ns | 178 ns | 15 ns |
{..1983-12-31,1984-10-10..1984-11-01,1984-11-05..} |
1.25 µs | 1.20 µs | 795 ns |
1985-02-30 (rejected) |
189 ns | — | — |
Licensed under either of Apache License 2.0 or MIT license at your option.