Government PDF Table Extractor – PDF to JSON avatar

Government PDF Table Extractor – PDF to JSON

Pricing

from $10.00 / 1,000 table extracteds

Go to Apify Store
Government PDF Table Extractor – PDF to JSON

Government PDF Table Extractor – PDF to JSON

PDF table extractor for government reports and annual accounts: extract tables from PDF to JSON with headers, typed records, page and position, confidence and sum checks. Joins multi-page tables, handles merged headers and Dutch number formats; Claude can read scanned pages, validated in code.

Pricing

from $10.00 / 1,000 table extracteds

Rating

0.0

(0)

Developer

Leyten Data

Leyten Data

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

7 hours ago

Last modified

Share

What does Government PDF Table Extractor do?

PDF table extractor for government reports, annual accounts and statistics: give it PDF URLs (budgets, annual reports, key-figure publications, price lists) and get every table back as clean JSON rows with the column headers, typed records, the page and position it came from, a confidence score and the checks that were run on it. Tables that continue over a page break come back as one table. A deterministic pass reads text-layer PDFs in under a second per document; an optional LLM pass (Claude) reads scanned or messy pages, and everything the model returns is validated in code before you see it.

Why tables from PDFs are hard, and what this Actor does about it

ProblemWhat happens here
Column headers spread over two or three lines, with a group label ("Jaarrekening", "Begroting") centred above several year columnsHeader rows are joined into one name per column and group labels are spread over the columns they cover: Jaarrekening 2020, Begroting 2025**
Borderless tables aligned by whitespace, and ruled tables with thin linesThree detectors run on every page (whitespace-aligned, ruled lines, and a second ruled-line reader); the best reading per table region is kept
A table continues on the next page, with or without the header printed againContinuations with the same columns are joined into one table with pages: [14, 15] and merged: true; the repeated header is dropped
Row labels that wrap onto two lines, and words cut in two at a column boundaryRe-joined
Section headings inside the table ("Mobiliteit in Nederland")Kept as rows, listed in sectionRows, excluded from column typing and sums
Dutch and English number formats (4.585 vs 4,585.00), negatives in parentheses, footnote marks on yearsThe format is decided per column, never per cell; records carry real numbers
TotalsEvery row labelled Totaal/Total/Som is checked against the rows above or below it, at the precision the table prints; a mismatch is reported in checks, never corrected
Text boxes and chart legends that look like two-column tablesDropped (prose filter, chart captions)
Scanned PDFs, faint scans, exotic layoutsllm-fallback sends only those pages to Claude; llm sends every page. The model transcribes, the code validates: column counts, numbers, sums, expected columns

Low confidence means a marking (lowConfidence: true, the reasons in checks and warnings), never a guess filled in for you.

The two modes

fast (default): deterministic only. Camelot 2.0 (stream and lattice) and pdfplumber read the text layer; no model, no API cost, about a second per document. Pages without a text layer (scans) are reported in the document record and yield no tables.

llm-fallback: the deterministic pass first; then Claude Haiku 4.5 (or Sonnet 5) reads the pages that have no text layer and the pages whose tables all came out with low confidence, from a rendered image of the page. Its answer goes through the same normalisation and checks as the deterministic result.

llm: Claude reads every page. For documents where layout beats the deterministic detectors (multi-level headers with merged cells, forms, brochures).

The model call logs its tokens and cost per page (LLM page 14 of report.pdf: Claude Haiku 4.5, 1,472 in / 812 out tokens, $0.0055 API cost, 1 table(s)) and the run summary totals them. The LLM events are priced to cover that cost.

How the choice of libraries was made

The deterministic engine is Camelot 2.0 (MIT, pdfium backend, no system dependencies) for whitespace-aligned and ruled tables, with pdfplumber (MIT) as a second reader of ruled lines. On Dutch government reports Camelot's stream mode was the only open-source detector that returned financial tables as correct grids including the row-label column; PyMuPDF's table finder is fast but AGPL-licensed, and the transformer models (Docling's TableFormer, Marker) score higher on benchmarks but need gigabytes of models and seconds per page on CPU, which does not fit a per-page price. Those hard pages are what the LLM mode is for.

Confidence and checks

confidence starts from the detector's own accuracy and whitespace measures (or the model's stated confidence) and is lowered for: no header row detected, rows with a different number of cells, columns that mix numbers and text, totals that do not add up, columns without a header name, and a column count that differs from Expected columns. Below 0.7 the table is lowConfidence. The checks array says exactly what was tested and what was found, for instance:

sum · ok: false · Totaal in column "Jaarrekening 2023": the 6 rows above add up to 40,54, the table prints 40,56

A failed sum check means the rows and the total as printed do not agree: either a cell was misread, or the table lists only some of the components. The Actor tells you; it does not decide for you.

Features

  • Extract tables from PDF to JSON: headers, rows, typed records, caption and footnotes per table
  • Multi-line and grouped headers joined into one name per column
  • Multi-page tables merged into one, with the repeated header dropped
  • Dutch and English number formats parsed per column into real numbers
  • Sum checks on totals and subtotals, reported, never silently corrected
  • Confidence score and explicit checks per table; low confidence is a marking, not a guess
  • Page, position and optional cell bounding boxes for every table
  • Three detectors (Camelot stream and lattice, pdfplumber lines) with the best reading kept per region
  • Optional Claude pass for scanned or messy pages, validated in code, with cost logged per page
  • Runs through the Apify API, on a schedule, or from Make, Zapier and n8n

Use cases

Data and RAG engineers: tables that survive chunking

Running text goes through chunkers fine; tables do not. Extract them once, store records next to the document with page and caption, and let the retriever return the table as structured data (or a Markdown rendering of headers + rows) instead of a scrambled paragraph.

// npm install apify-client
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('lwsdjfls/govt-pdf-table-extractor').call({
pdfUrls: ['https://open.overheid.nl/documenten/0ceeb3e8-e2b6-4237-9772-f75087e320ed/file'],
mode: 'fast',
});
const { items } = await client.dataset(run.defaultDatasetId).listItems({ clean: true });
for (const { payload } of items) {
if (payload.kind !== 'table') continue;
if (payload.lowConfidence) console.warn(`${payload.fileName} p.${payload.page}: check ${payload.checks.filter((c) => !c.ok).map((c) => c.detail).join('; ')}`);
await store.saveTable({ document: payload.documentId, page: payload.page, caption: payload.caption, headers: payload.headers, records: payload.records });
}

Policy analysts: government and statistical data

Budgets, annual reports and key-figure publications on open.overheid.nl, rijksfinancien.nl and CBS come as PDFs with the numbers in tables. The example below is Table 1 of the Integraal Overzicht Financiën Gemeenten 2025 (Ministry of the Interior, open.overheid.nl): the key financial figures of all municipalities 2020–2024, delivered as numbers. The same document's Table 2 (income per source, with the group headers Jaarrekening and Begroting spread over the year columns) comes out with every subtotal verified and one finding: the printed grand total for 2023 is 0,10 off the sum of its parts.

Finance and procurement teams

Price lists, statements and supplier reports: set Expected columns to the columns your import needs; tables that match get those names as record keys, tables that do not are flagged in checks and marked low confidence so nobody imports the wrong grid.

Journalists and researchers

Point it at a folder of reports (URLs), take the tables view, and filter on caption.

How to use it

  1. Paste the document links into PDF URLs, or post the files themselves as base64 in PDF files if they are not public. Narrow to the pages that matter with Pages, written like 14-17 or 1-5,8. Pages you do not ask for are never read and never charged.
  2. Leave Mode on fast first. That is the deterministic reader: it costs the least and, for any PDF with a real text layer, it is also the most accurate. Only if pages come back empty or with low confidence should you move to llm-fallback, which sends exactly those pages to Claude and leaves the rest alone. Reserve llm for documents that are scans from end to end.
  3. Read the tables view for the extracted tables and documents for what happened per file. Every table carries its page, its position, the headers, the confidence and the sum checks, so you can tell a clean extraction from one worth a human glance instead of trusting all of them equally.

Set Expected columns when you process the same report every quarter. Tables that match the count get your column names as record keys, and any table that does not is flagged rather than quietly renamed. Join tables that continue on the next page keeps a table that runs over a page break as one table with all its rows.

Nothing is silently accepted. Whatever the model reads on a scanned page is validated in code against the page before it is delivered, and a file that cannot be fetched or is larger than Maximum file size comes back as a failed document record that costs nothing, rather than disappearing from your results.

{
"pdfUrls": ["https://open.overheid.nl/documenten/dpc-cee2e3debdd8cd3e16f84a876ee24537e3e034c0/pdf"],
"pages": "14-17",
"mode": "fast"
}

Input

FieldWhat it doesExample
pdfUrlsDirect http(s) links to PDF files, one per line, each up to 2,048 characters; links to key-value store records work too. A link listed twice is read once.["https://open.overheid.nl/documenten/dpc-cee2e3debdd8cd3e16f84a876ee24537e3e034c0/pdf"]
pdfFilesFor API callers: base64 files, up to 100, within Apify's 9 MB input limit.[{ "fileName": "report.pdf", "data": "<base64>" }]
pagesPage numbers and ranges to read, for every document, such as 1-5, 8 (up to 1,000 characters); empty means all pages."14-17"
modefast (deterministic only), llm-fallback (Claude for scans and low-confidence pages) or llm (Claude reads every page)."fast"
llmModelhaiku (Claude Haiku 4.5, cheaper) or sonnet (Claude Sonnet 5, for dense or hard layouts)."haiku"
flavorauto, stream (whitespace only) or lattice (ruled lines only)."auto"
expectedColumnsThe column names you expect, in order; matching tables get them as record keys, others are flagged.["Post", "2023", "2024"]
minRowsTables with fewer body rows are ignored.2
minColumnsTables with fewer columns are ignored.2
mergeAcrossPagesJoin a table that continues on the next page.true
includeCellsAdd every cell with its page and bounding box to each table.false
maxPagesPerDocumentPages beyond this are not read or charged.200
maxFileMbLarger files are reported as failed and cost nothing; at most 100.50
maxLlmPagesPerRunCaps the pages sent to Claude in one run.200
proxyConfigurationOnly for downloading PDFs from sites that block data-centre addresses.{ "useApifyProxy": false }
{
"pdfUrls": ["https://open.overheid.nl/documenten/dpc-cee2e3debdd8cd3e16f84a876ee24537e3e034c0/pdf"],
"pages": "14-17",
"mode": "llm-fallback",
"llmModel": "haiku",
"mergeAcrossPages": true
}

For files you hold yourself, pass them base64-encoded in pdfFiles, or upload them to a key-value store and give the record URL in pdfUrls.

Supported input and limits

  • PDF files with a text layer (any producer); scanned or image-only PDFs in the LLM modes. Password-protected PDFs are not supported.
  • Up to 500 URLs or 100 base64 files per run, 50 MB per file by default (100 MB at most), 200 pages per document by default.
  • The same file twice (a URL listed twice, two URLs that serve identical bytes, or an upload of a file you also linked) is read and charged once; the repeat gets a document record with status duplicate.
  • After 20 documents in one run that cost nothing (not downloadable, not a PDF or too large, a repeat of a file already read, a PDF that cannot be opened, tables or a worker answer too large for the run's memory, or no page to process), the remaining documents are not tried; they are reported as failed, free of charge. The same happens once the run's spending limit is reached.
  • In the LLM modes the model's answer is capped at 4,096 output tokens per page. A page with more table text than that can come back incomplete, and the log says so.
  • A run stops sending pages to Claude when its API cost climbs far above what its LLM pages are charged (a safeguard against pages that cost more to read than their price); the remaining pages are delivered from the deterministic pass.
  • Tables drawn as images inside a text-layer PDF are not read in fast mode (the page has text, so it is not treated as a scan); use llm-fallback with a low-confidence outcome or llm.
  • Rotated pages and tables set sideways are read as they are; the detectors do not rotate.
  • Nested tables and tables with cells that span rows and columns in irregular ways come back as a flat grid with the spanning text in the first cell of the span.
  • Sum checks look for totals labelled Totaal, Total, Subtotaal, Som, Gesamt or Somme. A table lists at most 100 sum checks and 100 warnings; the rest are counted in a closing entry.
  • A table too large for one dataset item (about 8 MB of JSON, such as a table of thousands of rows joined across hundreds of pages) is delivered as consecutive items with the same caption, headers, footnotes and checks, each with a part field; it is charged as one table. A table lists at most 100 footnotes of 20,000 characters together; the rest are counted in a closing entry.
  • A table that cannot be delivered even in parts, which only a crafted file produces (a single row larger than an item, or headers and other repeated fields taking more than half of one), is left out as a whole, and the pages it is on are still charged. The document record counts it in tablesNotDelivered and names its page and size in error.
  • A run holds its tables in memory until it delivers them: up to 32 MB of table JSON per GB of memory (128 MB with the 4 GB maximum). A document whose tables do not fit in what is left is reported as failed and not charged (LLM pages already read excepted), and its error says when no run with this memory can hold it; once the run holds half of that and a document no longer fits, the remaining documents are not tried. In the LLM modes the tables on the pages the model does not read are measured first: a document they already make too large sends no page to Claude. The PDF worker's answer for one document is limited the same way (up to about 21 MB per GB, several hundred pages of a typical report). Give the run more memory, or read long documents in page ranges.
  • File names from the server (or from pdfFiles) are cleaned of control characters and cut to 200 characters.

Output

One record per table:

  • caption, headers, rows (cell text, null for empty), records (one object per row keyed by header, numbers parsed), footnotes
  • columnTypes (number, text, mixed, empty), numberFormat (nl or en), sectionRows
  • page, pages (every page the table covers), bbox in PDF points with a top-left origin, pageSize, optional cells with the page and a bounding box per cell (for a table joined across pages, the cells of every page)
  • part: null, or for a table delivered in parts its index, count, firstRow and tableRowCount; rows, records, sectionRows, cells and rowCount then hold that part's rows, and the tables view shows part beside each row
  • method: camelot-stream, camelot-lattice, pdfplumber-lines, llm-haiku or llm-sonnet
  • confidence (0–1), lowConfidence, checks (column-count, numeric-consistency, header, sum, expected-columns), warnings, merged, textLayer
  • llm: model, tokens and API cost when the model read the page
  • documentId, documentUrl, fileName, tableIndex

Plus one document record per file, with status processed, no-tables, failed or duplicate: pages processed, pages without a text layer, pages read by the LLM, tables found, tables not delivered (tablesNotDelivered), low-confidence tables, the LLM API cost, and the error when the file could not be fetched, opened or held in the run's memory (a failed document costs nothing, except LLM pages the model had already read, which its record lists).

Table 1 of the Integraal Overzicht Financiën Gemeenten 2025 (page 3): caption separated, year headers with footnote marks kept, Dutch thousands separators parsed into numbers, and a row of counts with percentages in parentheses reported in warnings rather than silently parsed.

{
"envelopeVersion": 1,
"id": "caced5b180e76c8d:t1",
"scope": "doc:caced5b180e76c8d",
"changeType": "added",
"sourceUrl": "https://open.overheid.nl/documenten/0ceeb3e8-e2b6-4237-9772-f75087e320ed/file",
"scrapedAt": "2026-09-15T20:00:00.000Z",
"firstSeenAt": "2026-09-15T20:00:00.000Z",
"hash": "71667740daeff13f87b94c1e0a9ea9b9f72cd895a4ee696c1266f8454a907c5f",
"previousHash": null,
"changes": [],
"summary": "Added: Integraal Overzicht Financien Gemeenten 2025.pdf p.3 table 1 (Tabel 1. Kengetallen financiële positie): 11×6",
"diffText": null,
"payload": {
"kind": "table",
"documentUrl": "https://open.overheid.nl/documenten/0ceeb3e8-e2b6-4237-9772-f75087e320ed/file",
"fileName": "Integraal Overzicht Financien Gemeenten 2025.pdf",
"documentId": "caced5b180e76c8d",
"page": 3,
"pages": [
3
],
"tableIndex": 1,
"caption": "Tabel 1. Kengetallen financiële positie",
"footnotes": [],
"headers": [
"label",
"2020",
"2021",
"2022",
"2023*",
"2024**"
],
"rows": [
[
"Exploitatiesaldo (€ mln.)",
"4.585",
"1.761",
"3.745",
"1.644",
"1.976"
],
[
"Eigen vermogen (€ mln.)",
"34.081",
"35.875",
"39.651",
"41.322",
"43.346"
],
[
"Materiële en immateriële vaste activa (€ mln.)",
"66.274",
"68.605",
"70.900",
"74.103",
"78.423"
],
[
"Solvabiliteit (%)",
"37%",
"38%",
"40%",
"40%",
"39%"
],
[
"Netto schuldquote, gecorrigeerd voor doorgeleende gelden (%)",
"48%",
"49%",
"42%",
"41%",
"41%"
],
[
"Exploitatieruimte (%)",
"7%",
"3%",
"5%",
"2%",
"2%"
],
[
"Aantal gemeenten met 3 jaar op rij negatief resultaat",
"69 (20%)",
"30 (9%)",
"5 (1%)",
"9 (3%)",
"10 (3%)"
],
[
"Grondexploitatie (afgezet tegen totale baten)",
"7%",
"6%",
"5%",
"5%",
"5%"
],
[
"EMU saldo (€ mln.)",
"-931",
"426",
"2.184",
"-1.223",
"-2.942"
],
[
"Stijging OZB-tarief, gecorrigeerd voor waardeontwikkeling (%)",
"4,9%",
"4,7%",
"3,6%",
"6%",
"5,9%"
],
[
"Netto investeringsquote (%)",
"1,0%",
"1,0%",
"0,8%",
"1,4%",
"1,9%"
]
],
"records": [
{
"2020": 4585,
"2021": 1761,
"2022": 3745,
"label": "Exploitatiesaldo (€ mln.)",
"2023*": 1644,
"2024**": 1976
},
{
"2020": 34081,
"2021": 35875,
"2022": 39651,
"label": "Eigen vermogen (€ mln.)",
"2023*": 41322,
"2024**": 43346
},
{
"2020": 66274,
"2021": 68605,
"2022": 70900,
"label": "Materiële en immateriële vaste activa (€ mln.)",
"2023*": 74103,
"2024**": 78423
},
{
"2020": 37,
"2021": 38,
"2022": 40,
"label": "Solvabiliteit (%)",
"2023*": 40,
"2024**": 39
},
{
"2020": 48,
"2021": 49,
"2022": 42,
"label": "Netto schuldquote, gecorrigeerd voor doorgeleende gelden (%)",
"2023*": 41,
"2024**": 41
},
{
"2020": 7,
"2021": 3,
"2022": 5,
"label": "Exploitatieruimte (%)",
"2023*": 2,
"2024**": 2
},
{
"2020": "69 (20%)",
"2021": "30 (9%)",
"2022": "5 (1%)",
"label": "Aantal gemeenten met 3 jaar op rij negatief resultaat",
"2023*": "9 (3%)",
"2024**": "10 (3%)"
},
{
"2020": 7,
"2021": 6,
"2022": 5,
"label": "Grondexploitatie (afgezet tegen totale baten)",
"2023*": 5,
"2024**": 5
},
{
"2020": -931,
"2021": 426,
"2022": 2184,
"label": "EMU saldo (€ mln.)",
"2023*": -1223,
"2024**": -2942
},
{
"2020": 4.9,
"2021": 4.7,
"2022": 3.6,
"label": "Stijging OZB-tarief, gecorrigeerd voor waardeontwikkeling (%)",
"2023*": 6,
"2024**": 5.9
},
{
"2020": 1,
"2021": 1,
"2022": 0.8,
"label": "Netto investeringsquote (%)",
"2023*": 1.4,
"2024**": 1.9
}
],
"columnTypes": [
"text",
"number",
"number",
"number",
"number",
"number"
],
"numberFormat": "nl",
"rowCount": 11,
"columnCount": 6,
"sectionRows": [],
"bbox": [
65.78,
90.41,
545.54,
310.51
],
"pageSize": {
"width": 595.25,
"height": 842
},
"method": "camelot-stream",
"confidence": 0.89,
"lowConfidence": false,
"checks": [
{
"kind": "column-count",
"ok": true,
"detail": "every row has 6 cells"
},
{
"kind": "numeric-consistency",
"ok": true,
"detail": "5 numeric column(s), all cells parse"
},
{
"kind": "header",
"ok": true,
"detail": "header: label | 2020 | 2021 | 2022 | 2023* | 2024**"
}
],
"warnings": [
"Column \"2020\" is numeric but 1 cell(s) are not numbers.",
"Column \"2021\" is numeric but 1 cell(s) are not numbers.",
"Column \"2022\" is numeric but 1 cell(s) are not numbers.",
"Column \"2023*\" is numeric but 1 cell(s) are not numbers.",
"Column \"2024**\" is numeric but 1 cell(s) are not numbers."
],
"merged": false,
"textLayer": true,
"cells": null,
"llm": null,
"part": null
}
}

Pricing

EventNameWhat it meansPrice
apify-actor-startActor startCharged by Apify when a run starts: once for a run of up to 1 GB of memory, and once more for every extra GB. It covers the first five seconds of compute.$0.00005 per run up to 1 GB of memory ($0.05 per 1,000)
page-processedPage processedOne PDF page read and searched for tables in the deterministic pass. Pages skipped by the page range or the per-document cap are free; a document that cannot be downloaded or opened costs nothing.$0.002 per page ($2.00 per 1,000)
table-extracted (primary)Table extractedOne table delivered as rows, records and metadata (page, position, headers, column types, confidence, checks). A table that continues over several pages counts once.$0.01 per table ($10.00 per 1,000)
llm-page-processedLLM page (Claude Haiku 4.5)One page read by Claude Haiku 4.5 from its image, for scanned pages or tables the deterministic pass could not read with confidence. Covers the API cost; tables found this way are charged as tables too.$0.03 per page ($30.00 per 1,000)
llm-page-processed-premiumLLM page (Claude Sonnet 5)One page read by Claude Sonnet 5, for dense or hard layouts where Haiku is not enough. Covers the API cost; tables found this way are charged as tables too.$0.06 per page ($60.00 per 1,000)

A 60-page report with 20 tables in fast mode costs $0.32. The same report in llm mode with Haiku costs $2.12 (60 LLM pages); with llm-fallback you pay the LLM price only for the pages that needed it. Documents that cannot be fetched, opened or held in the run's memory are not charged, except LLM pages the model had already read, and neither are pages skipped by the page range or the per-document cap. The deterministic pass on a page that the LLM later re-reads is not charged twice: the page price is charged once per page.

An LLM page is charged as soon as the model has answered, because that is when its API cost is incurred: it stays charged if the run is aborted or fails before the tables are delivered. A model call that fails is not charged. Page and table events are charged when the results are delivered. If the platform restarts a run while its pages are being read (a migration), or you resurrect an aborted run, the pages read before the restart are read and charged again, and Maximum LLM pages per run counts each attempt on its own.

FAQ

Why is a table split in two, or two tables joined?

Joining follows the column count and the header: a continuation with the same columns and the same header (or no header, starting at the top of the next page) is joined. Switch Join tables that continue on the next page off to get every page's table separately.

The header is label, column_2, column_3. Why?

No header row was recognised (the first row already held amounts). The header check says so and the confidence is lowered; give Expected columns to name them.

Can I get CSV or Excel instead of JSON?

The dataset can be exported as CSV or Excel from the Apify Console or API; records flattens well. For one CSV per table, render headers and rows.

Why does the LLM mode cost more?

Every page the model reads is an API call to Anthropic (about $0.01 per page with Haiku 4.5, $0.02 with Sonnet 5, September 2026). The LLM events in the pricing table cover that cost; the deterministic mode has no such cost and is therefore much cheaper. The log states per page what the call cost.

Is PDF table extraction with this Actor GDPR-compliant, and what happens to my documents?

Documents are downloaded into the run's temporary storage and deleted as soon as each one is processed (page images for the LLM after each batch of pages); nothing is kept between runs. In the LLM modes, page images go to the Anthropic API under the Actor owner's account (Anthropic does not train on API data); in fast mode nothing leaves the Apify platform. Results live only in your own run's dataset. If your documents contain personal data, you remain the controller under the GDPR; the example documents are public publications on open.overheid.nl, and the Actor is not affiliated with their publishers.

Support

A PDF whose tables come out wrong? Open an issue on the Actor page with the URL and the page number; layouts that fail are how the detectors get better.