Guide · AWS Textract · Tables

Textract Table Extraction in MCP Servers — CELL traversal, merged cells, headers

AWS Textract's TABLES feature detects tabular structures in documents and returns them as a graph of TABLE, CELL, and WORD blocks that you traverse to reconstruct the grid. For MCP server tools that need to extract financial tables, comparison grids, data matrices, or any structured columnar data from PDFs and images, Textract's table extraction produces consistent output regardless of whether the document uses visible grid lines, color banding, or borderless whitespace alignment. This guide covers the TABLE → CELL → WORD block graph, RowIndex/ColumnIndex coordinates (both 1-based), the MERGED_CELL EntityType for spanning cells, how to detect column header rows via the COLUMN_HEADER EntityType, SELECTION_ELEMENT cells for checkbox columns, and how to serialize the result into a 2D array or array of objects for downstream MCP tool use.

TL;DR

Request FeatureTypes: [FeatureType.TABLES] in AnalyzeDocument. Each TABLE block has CHILD relationships to CELL blocks. Each CELL has RowIndex and ColumnIndex (1-based), RowSpan and ColumnSpan (default 1), and CHILD relationships to WORD or SELECTION_ELEMENT blocks. Cells with EntityTypes: ['COLUMN_HEADER'] are header cells. Cells with EntityTypes: ['MERGED_CELL'] span multiple rows or columns — their content is duplicated into each spanned cell in Textract's response (check RowSpan > 1 or ColumnSpan > 1 to detect). Build a 2D grid array using RowIndex/ColumnIndex to place each cell, then optionally zip header row with data rows to produce an array of objects.

TABLE and CELL block relationship graph

The Textract response includes TABLE blocks at the top level. Each TABLE block has a Relationships array containing one entry with Type: 'CHILD' and a list of CELL block IDs. It may also have Type: 'TABLE_TITLE' and Type: 'TABLE_FOOTER' relationships pointing to TABLE_TITLE and TABLE_FOOTER blocks (available in the newer API versions). Within each CELL block, another CHILD relationship points to the WORD blocks that make up the cell text, or a SELECTION_ELEMENT block if the cell contains a checkbox.

Key CELL block fields:

There is no explicit "number of rows" or "number of columns" field on TABLE blocks. You determine grid dimensions by finding the maximum RowIndex and ColumnIndex among all cells that belong to that table.

Extracting all tables from a document

import {
  TextractClient,
  AnalyzeDocumentCommand,
  FeatureType,
  type Block,
} from '@aws-sdk/client-textract';

const client = new TextractClient({ region: 'us-east-1' });

type Grid = string[][];

interface ExtractedTable {
  tableIndex: number;
  title: string | null;
  headers: string[];
  rows: string[][];
  asObjects: Array<Record<string, string>>;
}

async function extractTables(bucket: string, key: string): Promise<ExtractedTable[]> {
  const resp = await client.send(new AnalyzeDocumentCommand({
    Document: { S3Object: { Bucket: bucket, Name: key } },
    FeatureTypes: [FeatureType.TABLES],
  }));

  const blocks = resp.Blocks ?? [];
  const index = new Map<string, Block>();
  for (const b of blocks) {
    if (b.Id) index.set(b.Id, b);
  }

  const tableBlocks = blocks.filter(b => b.BlockType === 'TABLE');
  return tableBlocks.map((table, i) => extractTable(table, index, i));
}

function getCellText(cell: Block, index: Map<string, Block>): string {
  const children = (cell.Relationships ?? [])
    .filter(r => r.Type === 'CHILD')
    .flatMap(r => r.Ids ?? [])
    .map(id => index.get(id))
    .filter((b): b is Block => b !== undefined);

  // Handle checkbox cells
  const selEl = children.find(b => b.BlockType === 'SELECTION_ELEMENT');
  if (selEl) return selEl.SelectionStatus === 'SELECTED' ? 'TRUE' : 'FALSE';

  // Regular text cells: concatenate WORD blocks
  return children
    .filter(b => b.BlockType === 'WORD')
    .map(b => b.Text ?? '')
    .join(' ');
}

function extractTable(
  table: Block,
  index: Map<string, Block>,
  tableIndex: number,
): ExtractedTable {
  // Get all cells for this table
  const cellIds = (table.Relationships ?? [])
    .filter(r => r.Type === 'CHILD')
    .flatMap(r => r.Ids ?? []);

  const cells = cellIds
    .map(id => index.get(id))
    .filter((b): b is Block => b !== undefined && b.BlockType === 'CELL');

  // Determine grid dimensions
  const maxRow = Math.max(...cells.map(c => (c.RowIndex ?? 1) + (c.RowSpan ?? 1) - 1));
  const maxCol = Math.max(...cells.map(c => (c.ColumnIndex ?? 1) + (c.ColumnSpan ?? 1) - 1));

  // Initialize empty grid
  const grid: Grid = Array.from({ length: maxRow }, () =>
    Array(maxCol).fill(''),
  );

  const headerRowIndices = new Set<number>();

  for (const cell of cells) {
    const row = (cell.RowIndex ?? 1) - 1;    // convert to 0-based
    const col = (cell.ColumnIndex ?? 1) - 1;
    const text = getCellText(cell, index);

    // Place text in primary cell position
    grid[row][col] = text;

    // For merged cells, fill spanned positions with the same text
    const rowSpan = cell.RowSpan ?? 1;
    const colSpan = cell.ColumnSpan ?? 1;
    for (let r = row; r < row + rowSpan; r++) {
      for (let c = col; c < col + colSpan; c++) {
        if (r !== row || c !== col) grid[r][c] = text;
      }
    }

    // Track header rows
    if ((cell.EntityTypes ?? []).includes('COLUMN_HEADER')) {
      headerRowIndices.add(row);
    }
  }

  // Extract table title if present
  const titleIds = (table.Relationships ?? [])
    .filter(r => r.Type === 'TABLE_TITLE')
    .flatMap(r => r.Ids ?? []);
  const titleText = titleIds.length
    ? (index.get(titleIds[0])?.Text ?? null)
    : null;

  // Separate header rows from data rows
  const headerRows = grid.filter((_, i) => headerRowIndices.has(i));
  const dataRows = grid.filter((_, i) => !headerRowIndices.has(i));

  // Use last header row as column names if headers exist
  const headers = headerRows.length ? headerRows[headerRows.length - 1] : [];

  // Convert data rows to array of objects if headers are available
  const asObjects: Array<Record<string, string>> = headers.length
    ? dataRows.map(row =>
        Object.fromEntries(headers.map((h, i) => [h || `col_${i}`, row[i] ?? '']))
      )
    : [];

  return { tableIndex, title: titleText, headers, rows: dataRows, asObjects };
}

Handling merged cells correctly

Merged cells are the most common source of incorrect table extraction. A merged cell in Textract is a CELL block with RowSpan > 1 or ColumnSpan > 1 (or both), and its EntityTypes may include 'MERGED_CELL'. The cell's text represents the content of the entire merged region. There is only one CELL block per merged region — Textract does not generate empty placeholder blocks for the covered rows/columns. If you naively place cells into a grid by their RowIndex/ColumnIndex without accounting for span, the cells below a row-spanning cell will overwrite the correct positions and the grid will be mis-aligned.

The extractTable function above handles this by explicitly filling all spanned positions with the merged cell's text, using a double loop over rowSpan/columnSpan. An alternative approach — if you want empty strings in spanned cells to more accurately represent the original table structure — is to only fill the primary position and leave spanned positions blank:

// Alternative: only place text at primary cell position, leave spanned cells empty
// Use when downstream tools (e.g., CSV importers) expect one value per physical cell
grid[row][col] = text;
// Do NOT fill spanned positions — leave them as empty string
// This means row-span cells only appear in the first row/column of their merge region

Choose based on what downstream processing needs: fill spanned positions when you're zipping header rows with data rows and headers are merged, leave them empty when producing CSV output for tools that expect tidy rectangular data.

Column header detection

Textract marks cells it determines are column headers with EntityTypes: ['COLUMN_HEADER']. This detection works well on documents with clear visual header formatting (bold text, background shading, top-row position). On documents with non-standard formatting, Textract may miss the header row or incorrectly mark a data row as a header.

A fallback strategy when EntityTypes-based header detection is unreliable: always treat the first row as the header row, regardless of EntityTypes. This is appropriate for well-structured PDF tables where the first row is always the column header:

// Fallback: treat first row as header unconditionally
const headerRow = grid[0] ?? [];
const dataRows = grid.slice(1);

const asObjects = dataRows.map(row =>
  Object.fromEntries(headerRow.map((h, i) => [h || `col_${i}`, row[i] ?? '']))
);

For documents with multi-row headers (two rows of column names where the top row contains group labels and the second row contains sub-labels), combine both header rows into compound keys:

// Multi-row header: combine row 0 (group) + row 1 (sub-label) as "Group — SubLabel"
const groupRow = grid[0] ?? [];
const subLabelRow = grid[1] ?? [];
const compoundHeaders = groupRow.map((g, i) =>
  g ? `${g} — ${subLabelRow[i] ?? ''}` : subLabelRow[i] ?? `col_${i}`
);
const dataRows = grid.slice(2);

TABLE_TITLE and TABLE_FOOTER blocks

Newer versions of the Textract SDK expose TABLE_TITLE and TABLE_FOOTER as relationship types on TABLE blocks (in addition to EntityTypes on CELL blocks). A TABLE_TITLE block contains the caption text that appears above the table; a TABLE_FOOTER block contains footnotes that appear below. These blocks have a Text field with the caption/footnote text.

// Extract table title and footer
function getTableCaption(table: Block, index: Map<string, Block>) {
  const titleIds = (table.Relationships ?? [])
    .filter(r => (r.Type as string) === 'TABLE_TITLE')
    .flatMap(r => r.Ids ?? []);

  const footerIds = (table.Relationships ?? [])
    .filter(r => (r.Type as string) === 'TABLE_FOOTER')
    .flatMap(r => r.Ids ?? []);

  const title = titleIds.map(id => index.get(id)?.Text ?? '').join(' ');
  const footer = footerIds.map(id => index.get(id)?.Text ?? '').join(' ');

  return { title: title || null, footer: footer || null };
}

Note: The SDK's TypeScript types for RelationshipType may not include 'TABLE_TITLE' and 'TABLE_FOOTER' as valid enum values in older SDK versions. Cast r.Type to string and compare with string literals to avoid type errors without relying on SDK version alignment.

Serializing table output for MCP tool responses

MCP tools return text content in their response. For tables, the most useful serializations depend on how the AI orchestrator will consume the data:

JSON array of objects — Best when the orchestrator will reason about specific column values or pass data to a downstream tool. Each row becomes an object keyed by column header. Requires clean header detection.

Markdown table — Best for display or for passing to a language model that will reason about the content. Readable but verbose for large tables.

CSV — Best for downstream data processing tools or when feeding to a code execution environment.

function tableToMarkdown(headers: string[], rows: string[][]): string {
  const header = `| ${headers.join(' | ')} |`;
  const divider = `| ${headers.map(() => '---').join(' | ')} |`;
  const body = rows.map(r => `| ${r.join(' | ')} |`).join('\n');
  return [header, divider, body].join('\n');
}

function tableToCsv(headers: string[], rows: string[][]): string {
  const escape = (s: string) =>
    s.includes(',') || s.includes('"') || s.includes('\n')
      ? `"${s.replace(/"/g, '""')}"`
      : s;
  const lines = [headers, ...rows].map(row => row.map(escape).join(','));
  return lines.join('\n');
}

// In MCP tool handler:
const tables = await extractTables(bucket, key);
const output = tables.map((t, i) => {
  const md = tableToMarkdown(t.headers, t.rows);
  return `## Table ${i + 1}${t.title ? `: ${t.title}` : ''}\n\n${md}`;
}).join('\n\n');

return { content: [{ type: 'text', text: output }] };

Multi-table documents and table ordering

When a document contains multiple tables, AnalyzeDocument returns all TABLE blocks in the response. The order of TABLE blocks in the Blocks array follows reading order (top to bottom, left to right) as Textract determines it. For multi-page documents processed via the async API, each TABLE block has a Page field indicating which page it was found on. Filter by Page to process tables from a specific page:

// Group tables by page
const tablesByPage = new Map<number, Block[]>();
for (const block of blocks.filter(b => b.BlockType === 'TABLE')) {
  const page = block.Page ?? 1;
  if (!tablesByPage.has(page)) tablesByPage.set(page, []);
  tablesByPage.get(page)!.push(block);
}

// Process only tables on page 2
const page2Tables = tablesByPage.get(2) ?? [];