Build research-ready datasets from EU legislative data — recitals, articles, points, and the network between them.
eurlex-builder is a configurable Python pipeline that turns the EUR-Lex / Cellar corpus into provision-level Parquet tables for quantitative research. It fetches every directive, regulation, decision, or communication you ask for, extracts text at the granularity you need (whole article, numbered paragraph, or lettered point), collects metadata + inter-document relations, and ships the result as one DuckDB file plus four Parquet tables.
It is, to our knowledge, the first open-source tool to extract EU legislative text at configurable sub-article granularity across EUR-Lex's heterogeneous document formats — from modern HTML to 1970s text-only pages to scanned PDFs.
The package accompanies Seidl and Kosti (2026), "Mapping Europe's Digital Acquis: A Granular History of EU Digital Policymaking", SocArXiv preprint, doi:10.31235/osf.io/7hv94_v1. Replication package: osf.io/suf9m.
- Sub-article granularity. One config switch chooses whether each article is one row, one row per numbered paragraph (
Art. 5(1)), or one row per lettered point (Art. 5(1)(a)) — following the EU Joint Practical Guide. - Six HTML structures + PDF fallback. Automatic detection across EUR-Lex's HTML eras (Standard OJ, Manual CSS, class-based, text-only, consolidated-norm, classless) with Docling/pymupdf for older docs that have no machine-readable HTML.
- Two query modes. Fixed (give us a list of CELEX IDs / procedure numbers) or descriptive (date range + doc types + optional EuroVoc keyword filter).
- Inter-document relations. Citations, amendments, legal basis, repeals, consolidations — all in one table for network analysis.
- Guarded translation built in. Non-English-only documents are translated via pinned Helsinki-NLP Opus-MT revisions, separately at document level (
works.full_text) and per-unit (text_units.text_translated). Token-bounded retries and quality guards reject degenerate output without replacing the source text. - Reproducible + resumable. A single YAML defines the dataset; a checkpoint table in DuckDB makes the pipeline restart-safe and incremental.
- Auditable runs. DuckDB stores every validated config, its SHA-256 hash, runtime versions, and completion status;
validatechecks structural integrity without modifying the database. - Parallel mode. Multi-threaded fetching with
parallel: true; sequential writes keep DuckDB contention-free.
Full-text retrieval works for every document type; granular extraction does not. Whatever the pipeline reaches,
works.full_textis fetched and stored — the caveats here concern the decomposition into text units. Regulations, directives, and decisions are validated against official documents and are what the accompanying paper is built on. Communications extract well but have known gaps; proposals (PC) and staff working documents (SC) lose units to modern templates, and case law (sector 6) yields zero text units — silently (and is reachable only in fixed mode). Read Verification status before running anything outside the validated three.
pip install "eurlex-builder[all]"Create a config.yaml. You can either request specific acts by CELEX ID (fixed mode) or search by date range and document type (descriptive mode):
Fixed mode — specific acts:
metadata:
project_name: "GDPR + AI Act"
data:
mode: "fixed"
celex_ids:
- "32016R0679" # GDPR
- "32024R1689" # AI Act
processing:
text_extraction:
article_granularity: paragraph
output:
output_directory: "./output"Descriptive mode — all acts matching a search:
metadata:
project_name: "EU digital regulations 2020–2025"
data:
mode: "descriptive"
document_types:
- "regulation"
- "directive"
start_date: 2020-01-01
end_date: 2025-12-31
filter_keywords: # optional EuroVoc filter
- "artificial intelligence"
- "data protection"
processing:
text_extraction:
article_granularity: paragraph
parallel: true
max_workers: 8
output:
output_directory: "./output"Run:
eurlex-builder run config.yamlYou'll get output/eurlex_builder.duckdb plus four Parquet files: works.parquet, text_units.parquet, relations.parquet, eurovoc.parquet. The database filename is always eurlex_builder.duckdb — it's the output_directory that separates projects, so give each config its own (e.g. ./output/digital-regs).
import polars as pl
text_units = pl.read_parquet("output/text_units.parquet")
# All paragraph-1 rows of GDPR Article 5
text_units.filter(
(pl.col("celex_id") == "32016R0679")
& (pl.col("type") == "article")
& (pl.col("number") == "5")
)| What it is | Why we built our own | |
|---|---|---|
| {eurlex} (R) | Document retrieval | Handles retrieval but not structured extraction or translation; we provide both at multiple granularities |
| EUPLEX | Complexity indicators | Publishes derived metrics (readability, word counts) without the underlying text |
| EUPROPS | Manually curated text | One-off snapshot; not re-runnable or extendable to new time windows / doc types / granularities |
| eurlex-builder | Configurable pipeline | Reproducible, provision-level output with structured extraction, translation, and inter-document relations — ready for NLP, classification, and network analysis |
Requires Python 3.11+.
# Core only (HTML extraction + parquet output)
pip install eurlex-builder
# With PDF support (Docling + pymupdf)
pip install "eurlex-builder[pdf]"
# With translation (Opus-MT via Hugging Face)
pip install "eurlex-builder[translate]"
# Everything (recommended for production runs)
pip install "eurlex-builder[all]"For development, install from a checkout instead:
git clone https://github.com/tseidl/eurlex-builder.git
cd eurlex-builder
python -m venv .venv && source .venv/bin/activate
# Core only (HTML extraction + parquet output)
pip install -e .
# With PDF support (Docling + pymupdf)
pip install -e ".[pdf]"
# With translation (Opus-MT via Hugging Face)
pip install -e ".[translate]"
# Everything (recommended for production runs)
pip install -e ".[all]"
# Plus dev tools (pytest)
pip install -e ".[dev]"A single YAML file defines the dataset. Only the data section is required; everything else has sensible defaults. Unknown keys and malformed CELEX IDs are rejected rather than silently ignored.
metadata — stamped onto the output, not used for filtering
| Field | Type | Default | Description |
|---|---|---|---|
project_name |
string | "eurlex-builder Dataset" |
Embedded in output metadata |
author |
string | "" |
Author name |
description |
string | "A dataset built with eurlex-builder." |
Free-text description |
version |
string | "1.0" |
Dataset version |
data (fixed mode) — specific documents
Provide CELEX IDs or interinstitutional procedure numbers. At least one is required.
data:
mode: "fixed"
celex_ids:
- "32016R0679"
procedure_numbers:
- "2021/0106" # resolved to CELEX IDs via SPARQL| Field | Type | Default | Description |
|---|---|---|---|
mode |
"fixed" |
— | Required |
celex_ids |
list of strings | [] |
CELEX identifiers |
procedure_numbers |
list of strings | [] |
Interinstitutional procedure refs (e.g. "2021/0106") |
data (descriptive mode) — search by date / type / keyword
data:
mode: "descriptive"
document_types:
- "regulation"
- "directive"
- "decision"
- "communication"
start_date: 1979-01-01
end_date: 2026-04-30
filter_keywords:
- "artificial intelligence"
- "digital"
include_corrigenda: false
include_consolidated_texts: false| Field | Type | Default | Description |
|---|---|---|---|
mode |
"descriptive" |
— | Required |
document_types |
list of strings | — | Required. At least one. Mapped to CELEX type codes (regulation→R, directive→L, decision→D, communication→DC, proposal→PC, staff working document→SC) |
start_date |
date | — | Required (YYYY-MM-DD) |
end_date |
date | — | Required, must be after start_date |
filter_keywords |
list of strings | [] |
EuroVoc keyword filter. Empty = no filter; no matched or accepted concepts stops discovery with an error |
include_corrigenda |
bool | false |
Include corrigenda |
include_consolidated_texts |
bool | false |
Include consolidated texts (CELEX sector 0) |
processing.text_extraction — structural extraction + granularity
| Field | Type | Default | Description |
|---|---|---|---|
include_recitals |
bool | true |
Extract recitals (preamble "whereas" clauses) |
include_articles |
bool | true |
Extract articles (operative provisions) |
include_annexes |
bool | true |
Extract annexes |
strip_boilerplate |
bool | true |
Strip signature blocks ("Done at Brussels…") and binding clauses from the last article |
store_raw_html |
bool | false |
Store decoded source markup in works.full_text_html, using its declared encoding without rewriting the HTML. Substantially increases DB size; useful for debugging |
article_granularity |
"article" | "paragraph" | "point" |
"article" |
One row per article (default), per numbered paragraph, or per lettered point. See Granularity below |
processing.translation — Opus-MT for non-English docs
| Field | Type | Default | Description |
|---|---|---|---|
translate_full_text |
bool | true |
Translate works.full_text (one big blob per doc) |
translate_text_units |
bool | true |
Translate text_units.text (per recital/article/paragraph) |
max_full_text_chars |
int | 100000 |
Skip full_text translation above this length. 0 disables the cap. text_units are still translated |
processing (top-level) — parallel, relations, EuroVoc
| Field | Type | Default | Description |
|---|---|---|---|
automated_mode |
bool | false |
Skip interactive EuroVoc keyword review |
parallel |
bool | false |
Multi-threaded fetching |
max_workers |
int (1–16) | 4 |
Number of parallel threads. Four is the conservative default because each PDF-processing thread owns a Docling worker. HTML-dominant runs may benefit from 8; benchmark on the target machine rather than assuming more workers are faster |
include_relations |
bool | true |
Fetch + store inter-document relations |
include_eurovoc |
bool | false |
Include EuroVoc descriptors in metadata fetch (also via the enrich command) |
fetch_original_recitals_for_consolidated |
bool | true |
Consolidated texts: fetch recitals from the original act |
fetch_original_relations_for_consolidated |
bool | true |
Consolidated texts: merge relations from original |
output
| Field | Type | Default | Description |
|---|---|---|---|
formats |
list | ["parquet"] |
"parquet" and/or "csv". Parquet is recommended |
output_directory |
string | "./output" |
Output path. Created if missing |
EU drafting convention recognises three operative levels (Joint Practical Guide of the Council, Commission, and Parliament):
Article 5
Paragraph 5(1)
Point 5(1)(a)
Point 5(1)(b)
eurlex-builder lets you emit each as a row:
article_granularity |
Row schema | Example: GDPR Art. 3 |
|---|---|---|
"article" (default) |
One row per article. Reproduces the conventional act-level view. paragraph_num and point_letter are NULL |
1 row |
"paragraph" |
One row per numbered paragraph (1., 2., 1a., …). paragraph_num set; point_letter NULL |
3 rows |
"point" |
One row per lettered point ((a), (b), …) when present, else per paragraph. Both columns set when applicable |
5 rows (paragraphs 1 and 3 → 1 row each; paragraph 2 → stem + (a) + (b)) |
A short preamble before paragraph 1. gets its own row with paragraph_num = "0". Articles in amending acts (lists of edits to other regulations) are flagged subtype = 'amendment_item'; substantive replacement text within them stays untagged so downstream classifiers can keep it.
Recitals are emitted identically across all granularity settings.
For legacy HTML, numbered table rows are accepted as recitals only when they
form a credible drafting sequence. Nested numbered tables remain inside their
parent recital, embedded sibling lists are excluded only when the main sequence
resumes, and a table continuation is joined to preceding paragraph recitals
only when every earlier number is present. For legacy PDFs, a
page-break fragment such as (1) of Regulation ... remains part of the active
recital instead of starting a false numbered sequence.
Run the pipeline.
| Argument | Description |
|---|---|
config |
YAML config file |
--fresh |
Clear checkpoints for the selected documents and re-process them |
--retry-failed |
Re-attempt previously failed docs |
--limit N |
Process only the first N remaining documents; later runs resume the rest |
The pipeline resumes by default, skipping checkpointed docs. After an
interrupted --fresh rebuild, re-run without --fresh to resume the new
rebuild rather than clearing its completed checkpoints again.
--limit is intended for canary runs and cannot be combined with --fresh.
If any document attempted in this invocation fails, the command exits with
status 1 after saving and exporting successful results. Its run manifest
records complete_with_failures. Historical failures outside the attempted
selection do not affect this exit status. Use --retry-failed to retry failures.
A Python call to Pipeline.run() returns RunResult(processed, failed) with
counts for the invocation; discovery and other fatal errors still raise.
Keyword filtering stops with an error if no EuroVoc concepts match or all
matches are rejected. Set filter_keywords: [] explicitly for an unfiltered
date/type search. Failed selection leaves existing checkpoints intact.
Translate non-English content. Resumable; skips already-translated rows.
| Argument | Description |
|---|---|
db |
Path to the DuckDB file |
--max-full-text-chars |
Cap for works.full_text translation (default 100000) |
--no-full-text |
Skip works.full_text phase |
--no-text-units |
Skip text_units.text phase |
--retry-rejected |
Explicitly retry unchanged rows previously rejected by the translation quality policy |
Translation is interruption-safe at row level. Rejected outputs remain in the
source language and are recorded in _translation_failures; an ordinary resume
skips unchanged current-policy rejections, while --retry-rejected tries them
again. A source-text or policy-version change invalidates the corresponding
ledger entry automatically.
Add post-hoc metadata via SPARQL — no re-fetching content. Adds entry-into-force date, ELI, author institutions, subject matter, procedure type/reference/legal basis, EuroVoc descriptors, and repeal relations.
| Argument | Description |
|---|---|
db |
Path to the DuckDB file |
--select |
Categories: metadata, relations, eurovoc. Default: all |
--parallel |
Fetch SPARQL queries in parallel |
--max-workers |
Number of parallel workers (default 4) |
--force |
Re-enrich already-enriched docs |
Completion is tracked separately for metadata, relations, and eurovoc, so categories can be run independently and resumed later. A successful response includes category completion sentinels; only then can an empty refresh remove stale values. Databases enriched by older releases are migrated to all-category checkpoints from enriched_at instead of unexpectedly re-fetching every work.
Print checkpoint summary (processed / failed counts + failure reasons).
Run read-only integrity checks for checkpoint/work consistency, orphaned rows, stable unit identity, duplicate keys/order, translated-fallback markers, and degenerate stored translations. The command exits non-zero when an error is found and reports expected data gaps and recorded translation rejections as warnings.
| File | Description |
|---|---|
eurlex_builder.duckdb |
Working database with data tables, checkpoints, and run manifests |
works.parquet |
One row per document |
text_units.parquet |
One row per recital / article (or paragraph / point at sub-article granularity) / annex |
relations.parquet |
One row per inter-document relation |
eurovoc.parquet |
EuroVoc descriptors per document (populated by enrich) |
pipeline.log |
Full run log |
missing_content.tsv |
Docs with no text in any language (kept in works as empty rows) |
non_english_content.tsv |
Docs with non-English content (candidates for translation) |
works — one row per document
| Column | Type | Description |
|---|---|---|
celex_id |
VARCHAR PK | CELEX identifier (e.g. 32016R0679) |
title |
VARCHAR | English title from SPARQL; falls back to another language when no English title exists |
date_adopted |
DATE | Document adoption date |
document_type |
VARCHAR | Derived from CELEX type code |
language |
VARCHAR | Language of fetched content; NULL when no content could be fetched |
full_text |
VARCHAR | Full document text (translated to English if non-English source) |
full_text_original |
VARCHAR | Original-language text (non-English docs only) |
full_text_html |
VARCHAR | Decoded source HTML markup (only if store_raw_html: true); not an archive of exact response bytes |
content_source |
VARCHAR | Provenance tag (cellar_html_eng, cellar_pdf_fra, …). Suffixes record fallbacks — see below |
date_entry_into_force |
DATE | Populated by enrich |
date_end_of_validity |
DATE | Populated by enrich; 9999-12-31 if still in force |
is_in_force |
BOOLEAN | Populated by enrich |
eli |
VARCHAR | European Legislation Identifier URI |
author |
VARCHAR | Author institution(s) (e.g. EP; CONSIL) |
subject_matter |
VARCHAR | EU subject matter classification(s) |
procedure_type |
VARCHAR | e.g. OLP for ordinary legislative |
procedure_reference |
VARCHAR | e.g. 2012/0011/COD |
procedure_legal_basis |
VARCHAR | Treaty legal basis |
enriched_at |
TIMESTAMP | Most recent enrichment timestamp; category completion is tracked internally in _enrichment_checkpoint |
content_source suffixes. The base tag names the format and language that
supplied the text. Suffixes record fallback and recovery steps:
__pdf_<lang>_fallback_<structures>— corroborated units of the named structural types were added from a same-language PDF while the HTML text was retained.__pymupdf_<reason>— degraded PDF extraction after a Docling timeout, partial result, crash, conversion error, oversize guard, or empty result.__translated— translate-before-extract output (see the FAQ).- Since 0.3.0,
__recital_footnotesand__preamble_columnsrecord conservative preamble cleanup on the PyMuPDF fallback;__pymupdf_headingsrecords a Docling heading move corroborated against the same PDF's text layer. The storedfull_textretains the original text layer.
text_units — one row per recital / article / paragraph / point / annex
| Column | Type | Description |
|---|---|---|
id |
INTEGER PK | Internal surrogate key; may change after re-extraction |
celex_id |
VARCHAR FK | Parent document |
unit_order |
INTEGER | Stable one-based order within a document |
unit_key |
VARCHAR | Deterministic key built from CELEX and structural coordScan report · 2026-10-17
From the balcony · 1 of 4 clapped
Schnitzel, Cap'm Slop and Princess read it and passed. Their reasons are on the balcony, with every other verdict. Critics are accounts on this site with no GitHub account behind them. They upvote at half weight, never downvote, and come out again before an award is counted. Who they are. report this listing— log in to report |

0 comments
log in to comment.