A scanned invoice has an amount next to the word "Total." A W-2 has a number in the box labeled "Wages." An expense report has a grid of dates, descriptions, and dollar figures. None of that is just text - it's text with a role, and that role is exactly what AnalyzeDocument's FORMS and TABLES feature types add on top of raw OCR.
This page covers pulling both out of a document and turning the block graph into a plain dictionary and a plain grid your application can actually use.
Extract every key-value pair into a plain dictionary and every table into a list of rows, by walking the Blocks graph once and grouping by BlockType.
# --- Python (boto3) ---import boto3textract = boto3.client("textract")with open("invoice.png", "rb") as f: response = textract.analyze_document( Document={"Bytes": f.read()}, FeatureTypes=["FORMS", "TABLES"], )blocks = response["Blocks"]by_id = {b["Id"]: b for b in blocks}def block_text(block: dict) -> str: parts = [] for rel in block.get("Relationships", []): if rel["Type"] != "CHILD": continue for child_id in rel["Ids"]: child = by_id[child_id] if child["BlockType"] == "WORD": parts.append(child["Text"]) elif child["BlockType"] == "SELECTION_ELEMENT": parts.append(f"[{child['SelectionStatus']}]") return " ".join(parts)# Forms: pair each KEY block with its VALUE block via the "VALUE" relationship.fields = {}for block in blocks: if block["BlockType"] != "KEY_VALUE_SET" or "KEY" not in block.get("EntityTypes", []): continue key_text = block_text(block) value_id = next( (vid for rel in block.get("Relationships", []) if rel["Type"] == "VALUE" for vid in rel["Ids"]), None, ) value_text = block_text(by_id[value_id]) if value_id else "" fields[key_text] = value_text# Tables: for each TABLE, place each CELL by RowIndex/ColumnIndex into a grid.tables = []for block in blocks: if block["BlockType"] != "TABLE": continue cell_ids = [cid for rel in block.get("Relationships", []) if rel["Type"] == "CHILD" for cid in rel["Ids"]] grid: dict[int, dict[int, str]] = {} for cid in cell_ids: cell = by_id[cid] row, col = cell["RowIndex"], cell["ColumnIndex"] grid.setdefault(row, {})[col] = block_text(cell) rows = [grid[r] for r in sorted(grid)] tables.append(rows)print(fields)print(tables[0] if tables else "no tables found")
// --- TypeScript (AWS SDK v3) ---import { TextractClient, AnalyzeDocumentCommand, Block } from "@aws-sdk/client-textract";import { readFileSync } from "node:fs";const textract = new TextractClient({});const response = await textract.send(new AnalyzeDocumentCommand({ Document: { Bytes: readFileSync("invoice.png") }, FeatureTypes: ["FORMS", "TABLES"],}));const blocks: Block[] = response.Blocks ?? [];const byId = new Map(blocks.map((b) => [b.Id, b]));function blockText(block: Block): string { const parts: string[] = []; for (const rel of block.Relationships ?? []) { if (rel.Type !== "CHILD") continue; for (const childId of rel.Ids ?? []) { const child = byId.get(childId); if (child?.BlockType === "WORD") parts.push(child.Text ?? ""); else if (child?.BlockType === "SELECTION_ELEMENT") parts.push(`[${child.SelectionStatus}]`); } } return parts.join(" ");}// Forms: pair each KEY block with its VALUE block via the "VALUE" relationship.const fields: Record<string, string> = {};for (const block of blocks) { if (block.BlockType !== "KEY_VALUE_SET" || !block.EntityTypes?.includes("KEY")) continue; const keyText = blockText(block); const valueId = (block.Relationships ?? []).find((rel) => rel.Type === "VALUE")?.Ids?.[0]; const valueBlock = valueId ? byId.get(valueId) : undefined; fields[keyText] = valueBlock ? blockText(valueBlock) : "";}// Tables: for each TABLE, place each CELL by RowIndex/ColumnIndex into a grid.const tables: string[][][] = [];for (const block of blocks) { if (block.BlockType !== "TABLE") continue; const cellIds = (block.Relationships ?? []).filter((r) => r.Type === "CHILD").flatMap((r) => r.Ids ?? []); const grid = new Map<number, Map<number, string>>(); for (const cid of cellIds) { const cell = byId.get(cid); if (!cell) continue; const row = cell.RowIndex ?? 0; const col = cell.ColumnIndex ?? 0; if (!grid.has(row)) grid.set(row, new Map()); grid.get(row)!.set(col, blockText(cell)); } const rows = [...grid.keys()].sort((a, b) => a - b).map((r) => [...grid.get(r)!.values()]); tables.push(rows as unknown as string[][]);}console.log(fields);console.log(tables[0] ?? "no tables found");
What this demonstrates:
block_text/blockText is the one reusable primitive - every block type (KEY, VALUE, CELL) resolves to a string the same way, by walking its CHILD relationship to WORD and SELECTION_ELEMENT blocks.
Form pairing follows the KEY block's VALUE relationship to find its paired VALUE block - the label and the answer are always two separate blocks joined by an Id reference, never one.
Table reconstruction relies entirely on RowIndex/ColumnIndex on each CELL, not on array order - cells can appear in any order within the Blocks list.
Checkboxes and selection elements resolve to a bracketed status string here for clarity; a real integration would branch on SelectionStatus directly instead of stringifying it.
A single form field - one label, one answer - is represented as two separate KEY_VALUE_SET blocks, distinguished by their EntityTypes field: one has EntityTypes: ["KEY"], the other EntityTypes: ["VALUE"]. The KEY block carries a Relationships entry of type VALUE whose Ids point at its paired VALUE block. Both blocks separately carry CHILD relationships pointing at the WORD (or SELECTION_ELEMENT) blocks that make up their own text. There is no single block that represents "field" - you always assemble one from two.
A field whose value was left blank on the source document still produces a KEY block and a VALUE block, but the VALUE block's CHILD relationship is empty or absent - block_text/blockText naturally returns an empty string in that case rather than erroring, which is the behavior you want for a genuinely blank field.
A TABLE block's CHILD relationship lists every CELL in the table, in no particular guaranteed reading order. Each CELL carries RowIndex and ColumnIndex (1-based) placing it in the grid, plus RowSpan and ColumnSpan for merged cells. A cell spanning two columns still appears once, with ColumnSpan: 2 - it is not duplicated across both column positions. Handling merged cells correctly means checking spans before assuming every row has the same number of columns.
Textract also detects table type via a EntityTypes value on newer API versions - some tables carry a TABLE_TITLE or TABLE_FOOTER block above or below the grid itself, which won't have RowIndex/ColumnIndex and should be handled as a caption rather than a data row.
When a form contains a checkbox or radio button, the corresponding VALUE block's CHILD relationship points at a SELECTION_ELEMENT block instead of a WORD block. That block carries SelectionStatus, either SELECTED or NOT_SELECTED, plus its own Confidence. Code that only handles WORD children will silently produce empty strings for every checkbox field - branch on BlockType explicitly, as block_text/blockText does above, rather than assuming every value is text.
FeatureTypes: ["FORMS", "TABLES"] in one AnalyzeDocument call returns both KEY_VALUE_SET and TABLE/CELL blocks in the same response - there is no need for two calls against the same document. This matters for documents that mix both, like an invoice with a header of key-value fields above a line-item table.
Assuming Blocks array order matches visual layout. Blocks come back in no guaranteed reading or table order. Fix: always group by RowIndex/ColumnIndex for tables and follow explicit Relationships for forms, never rely on array position.
Treating a KEY_VALUE_SET block as containing text directly. Neither the KEY nor VALUE block has a Text field of its own. Fix: walk its CHILD relationship to the underlying WORD blocks and join them.
Missing blank fields. A VALUE block with no CHILD relationship is a legitimately empty field, not an error. Fix: default to an empty string rather than throwing when a relationship is absent.
Not handling SELECTION_ELEMENT children. Code written only for WORD children silently drops every checkbox or radio button value. Fix: branch on BlockType and read SelectionStatus for SELECTION_ELEMENT children.
Ignoring RowSpan/ColumnSpan on merged cells. Assuming every row has an equal, unspanned column count breaks on real-world tables with merged headers. Fix: read RowSpan/ColumnSpan and fill the grid accordingly before assuming uniform rows.
Sending a multi-page PDF to AnalyzeDocument. The synchronous operation only accepts a single page. Fix: use StartDocumentAnalysis/GetDocumentAnalysis for anything with more than one page.
Do I need two separate calls for forms and tables?
No. FeatureTypes: ["FORMS", "TABLES"] in a single AnalyzeDocument call returns KEY_VALUE_SET blocks and TABLE/CELL blocks together in the same response.
Why are there two KEY_VALUE_SET blocks per field instead of one?
Textract represents the label and the answer as separate blocks so each can carry its own text, confidence, and geometry independently, then links them with a VALUE relationship - a design that also lets a value be empty without needing a special case for the key.
How do I know a table cell's position in the grid?
Read RowIndex and ColumnIndex directly off the CELL block - both are 1-based. Don't rely on the order cells appear in the TABLE block's CHILD relationship list.
What happens with a checkbox or radio button field?
The VALUE block's CHILD relationship points at a SELECTION_ELEMENT block instead of a WORD block, carrying SelectionStatus (SELECTED or NOT_SELECTED) and its own confidence score.
Can AnalyzeDocument handle a multi-page PDF for forms and tables?
No, the synchronous AnalyzeDocument operation only accepts a single page. Multi-page documents need StartDocumentAnalysis and GetDocumentAnalysis, covered in this section's async page.
What does an empty VALUE block mean?
A field the source document left blank. Its CHILD relationship is absent or empty - treat this as a legitimate empty value, not a parsing failure.
Is there a library that does this graph-walking for me?
AWS publishes amazon-textract-response-parser (Python and JavaScript) that wraps this same block traversal into a friendlier object model - useful once you understand what it's doing underneath, which this page covers directly.