This extension adds Markdown processing capabilities to DuckDB, enabling structured analysis of Markdown documents and content extraction for documentation analysis, content auditing, and knowledge base processing.
- Markdown Content Extraction: Extract code blocks, links, images, and tables from Markdown text
- COPY TO Markdown: Export query results as Markdown tables or reconstruct documents from sections
- Documentation Analysis: Analyze large documentation repositories with SQL queries
- Cross-Platform Support: Works on Linux, macOS, Windows, and WebAssembly (browsers)
- GitHub Flavored Markdown: Uses cmark-gfm for accurate parsing of modern Markdown
- Optional Wiki / Obsidian Extraction: Opt into
[[wikilinks]],![[embeds]], and#tagsextraction via theextract_extensionsparameter on the readers — or callmd_extract_wikilinks/md_extract_tagsdirectly on a markdown string - High Performance: Process thousands of documents efficiently with robust glob pattern support
-- Install from community extensions (when available)
INSTALL markdown FROM community;
LOAD markdown;git clone https://github.com/teaguesterling/duckdb_markdown
cd duckdb_markdown
make
make test-- Load the extension
LOAD markdown;
-- Read Markdown files with glob patterns
SELECT content FROM read_markdown('docs/**/*.md');
-- Read documentation sections with hierarchy
SELECT title, level, content
FROM read_markdown_sections('README.md', include_content := true);
-- Extract code blocks from Markdown text
SELECT cb.language, cb.code
FROM (
SELECT UNNEST(md_extract_code_blocks('```python\nprint("Hello, World!")\n```')) as cb
);
-- Analyze documentation repositories
SELECT
len(md_extract_code_blocks(content)) as code_examples,
len(md_extract_links(content)) as external_links,
len(md_extract_images(content)) as images
FROM read_markdown('**/*.md');
-- Use replacement scan syntax for convenience
SELECT * FROM '*.md';
SELECT * FROM 'docs/**/*.md';
-- Export results as Markdown table
COPY (SELECT * FROM my_table) TO 'output.md' (FORMAT MARKDOWN);
-- Round-trip: read sections, process, write back
COPY (
SELECT level, title, upper(content) as content
FROM read_markdown_sections('doc.md')
) TO 'processed.md' (FORMAT MARKDOWN, markdown_mode 'document');The extension supports DuckDB's replacement scan feature, allowing you to query Markdown files using table-like syntax:
-- Query markdown files directly
SELECT * FROM 'README.md';
SELECT * FROM '*.md';
SELECT * FROM 'docs/**/*.md';
-- Equivalent to calling read_markdown()
SELECT * FROM read_markdown('README.md');
SELECT * FROM read_markdown('*.md');
SELECT * FROM read_markdown('docs/**/*.md');Supported patterns in replacement scan:
'file.md'- Individual markdown files'*.md','**/*.markdown'- Glob patterns- Recursive patterns like
'docs/**/*.md'
Note: Directory patterns like 'docs/' are not supported. Use recursive globs like 'docs/**/*.md' instead.
Read Markdown files directly with comprehensive parameter support:
Reads Markdown files and returns one row per file.
Parameters:
files(required) - File path, glob pattern, directory, or list of mixed patternsfilename := false- Append a trailingfilenamecolumn naming the source file (deprecated alias:include_filepath). Boolean only: the string form that renames the column is deliberately not accepted, because a renamed column produces a struct no duck_block consumer accepts.content_as_varchar := false- Return content as VARCHAR instead of MARKDOWN typemaximum_file_size := 16777216- Maximum file size in bytes (16MB default)extract_metadata := true- Extract frontmatter into themetadatacolumn. This uses the same line-split key/value reader asmd_extract_metadata— each line split on the first:(or the first=inside a+++TOML block), typed asMAP(VARCHAR, VARCHAR)— not a full YAML parser (nested maps, lists, and multiline|/>scalars are not interpreted). For real YAML, use theyamlextension'sread_yaml_frontmatter— see Frontmatter Handling.normalize_content := true- Normalize Markdown contentextract_extensions := NULL- Opt-in add-on extractors (comma-separated VARCHAR; see Optional Add-On Extractors). When set, addswikilinksand/ortagsLIST<STRUCT>columns to the output
Returns: (content MARKDOWN, metadata MAP(VARCHAR, VARCHAR)) or (content MARKDOWN, metadata MAP(VARCHAR, VARCHAR), filename VARCHAR) with filename := true -- provenance is appended, never prepended. With extract_extensions, the requested add-on columns are appended.
Reads Markdown files and parses them into block-level elements (headings, paragraphs, code blocks, lists, tables, etc.).
Parameters:
files(required) - File path, glob pattern, or list of patternsfilename := false- Append a trailingfilenamecolumn naming the source file (deprecated alias:include_filepath). Boolean only: the string form that renames the column is deliberately not accepted, because a renamed column produces a struct no duck_block consumer accepts.extract_extensions := NULL- Opt-in add-on extractors (see Optional Add-On Extractors). When set, adds per-blockwikilinksand/ortagscolumns extracted from each block's content.
Returns: (kind VARCHAR, element_type VARCHAR, content VARCHAR, level INTEGER, encoding VARCHAR, attributes MAP(VARCHAR, VARCHAR), element_order INTEGER). level is structural depth, minimum 1 — a heading's rank is attributes['heading_level'], not level. A heading carries both a flattened plain-text title in content (what section_id and the sections API read) and its formatted text as inline children, so # **Bold** t keeps its emphasis instead of collapsing to # Bold t. (read_markdown_sections has a level column too, but that one is heading rank, 0-6.) With extract_extensions, the requested add-on columns are appended.
Note on level vs heading_level: For headings, the H1-H6 level is stored in attributes['heading_level'] (preferred). If not present, the level field is used as a fallback.
Element Types: heading, paragraph, code, blockquote, list, table, hr, metadata
Frontmatter is emitted as element_type = 'metadata' with attributes['role'] = 'frontmatter', carrying the raw text between the fences. The block keeps the position the source gave it, so a --- fence at the head of a file is the first element with role = 'frontmatter'. (The role is a positional claim: tailmatter if the author placed it after the body, document if the blob is the whole document, and no role at all when the format supplied metadata with no position — pandoc's meta, an EPUB's OPF — in which case the producer appends it and claims nothing.) duck_blocks_to_md finds the block by type wherever it sits and always writes it at the top, because markdown has nowhere else to put it: a fence at the bottom of a file is a thematic break, not metadata. Both dialects are recognised: --- fences give encoding = 'yaml', and Hugo-style +++ fences give encoding = 'toml'. The fence is restored from the encoding on write, so a TOML document round-trips as TOML rather than being relabelled YAML. The type says it is a verbatim metadata blob; the role says which source construct produced it. (Before spec 6.2 this was element_type = 'frontmatter', which was never a declared duck_block type. duck_blocks_to_md still accepts the old name so stored data keeps rendering.)
Encoding: text for plain content, json for table content, yaml/toml for frontmatter.
Lists are structural: a list carries no content of its own, and its items are list_item children with their text in paragraph blocks beneath them, one level deeper at each step (list → list_item → paragraph → inlines). Nested lists are list blocks inside a list_item. Before this, a list's items were packed into a JSON array in its content, which flattened inline formatting and discarded nested lists entirely.
-- Parse document into blocks
SELECT element_type, content, level
FROM read_markdown_blocks('README.md')
ORDER BY element_order;
-- Extract all code blocks with their languages
SELECT content, attributes['language'] as lang
FROM read_markdown_blocks('docs/**/*.md')
WHERE element_type = 'code';
-- Round-trip: read blocks, modify, write back
COPY (
SELECT kind, element_type, content, level, encoding, attributes
FROM read_markdown_blocks('doc.md')
) TO 'copy.md' (FORMAT MARKDOWN, markdown_mode 'blocks');Reads Markdown files and parses them into hierarchical sections.
Parameters:
files(required) - File path, glob pattern, directory, or list of mixed patterns. Supports fragment syntax:'file.md#section-id'to filter to a specific section and its descendants.include_content := true- Include section content in outputmin_level := 1- Minimum heading level to include (1-6)max_level := 6- Maximum heading level to include (1-6)content_mode := 'minimal'- Content extraction mode (see below)max_depth := 6- Maximum depth relative to min_level (e.g.,max_depth := 2withmin_level := 1includes h1 and h2 only)max_content_length := 0- Maximum content length for 'smart' mode (0 = auto, uses 2000 chars)include_empty_sections := false- Include sections without contentfilename := false- Append a trailingfilenamecolumn naming the source file (deprecated alias:include_filepath). Boolean only: the string form that renames the column is deliberately not accepted, because a renamed column produces a struct no duck_block consumer accepts.extract_metadata := true- Include frontmatter as a special section (level=0)extract_extensions := NULL- Opt-in add-on extractors (see Optional Add-On Extractors). When set, adds per-sectionwikilinksand/ortagscolumns extracted from each section's content.- Plus all
read_markdownparameters
Content Modes:
'minimal'(default) - Content stops at ANY next heading. Each section contains only its immediate content, not subsections.'full'- Content includes all subsections until next same-or-higher level heading. Use this for complete section extraction.'smart'- Adaptive mode: includes small subsections fully, truncates large ones with references like"... (see #subsection-id)".
Returns: (section_id VARCHAR, section_path VARCHAR, level INTEGER, title VARCHAR, content MARKDOWN, parent_id VARCHAR, start_line BIGINT, end_line BIGINT) or with filename := true appends a trailing filename VARCHAR column.
Notes:
- When
extract_metadata := true, the frontmatter block is included as a special section withlevel=0,section_id='frontmatter', and the raw, unparsed text between the---(or+++) delimiters as the content. Thislevelis heading rank (0 for frontmatter, 1-6 for headings) and is a different measurement fromread_markdown_blocks'level, which is structural depth and is 1 for every top-level element including frontmatter. Nothing in this extension interprets that text as YAML — see Frontmatter Handling. - The
section_pathcolumn provides hierarchical navigation paths like"parent/child/grandchild". - Fragment syntax
'file.md#section-id'returns the matching section and all its descendants.
All extraction functions return LIST<STRUCT> types for easy SQL composition:
md_extract_code_blocks(markdown)- Extract code blocks with language and metadatamd_extract_links(markdown)- Extract standard[text](url)links with text, URL, title, line numbermd_extract_images(markdown)- Extract images with alt text and metadatamd_extract_table_rows(markdown)- Extract table data as individual cellsmd_extract_tables_json(markdown)- Extract tables as structured JSON with enhanced metadatamd_extract_wikilinks(markdown)- Extract Obsidian/wiki-style links:[[target]],[[target|alias]],[[target#heading]],[[target^block]], and embeds![[…]]. ReturnsLIST<STRUCT(target, alias, anchor, is_embed, line_number)>. Not CommonMark/GFM — a lightweight linear scan (no std::regex); see Optional Add-On Extractors.md_extract_tags(markdown)- Extract inline#tag/#nested/tagreferences; skips fenced code blocks and inline code spans. ReturnsLIST<STRUCT(tag, line_number)>.
md_extract_wikilinks and md_extract_tags cover the Obsidian / wiki Markdown superset that cmark-gfm cannot parse — [[wikilinks]], ![[embeds]], #tags. They are a lightweight linear scan, not part of CommonMark/GFM, and are exposed two ways:
- Directly as scalar functions — call them on a markdown string you already have in hand.
- As opt-in add-on columns on the readers, via the
extract_extensionsnamed parameter — comma-separated VARCHAR, defaultNULL(no add-ons; existing behavior unchanged):
-- Single feature
SELECT wikilinks FROM read_markdown('vault/**/*.md', extract_extensions := 'wikilinks');
-- Multiple features (comma-separated, whitespace tolerant)
SELECT wikilinks, tags FROM read_markdown('vault/**/*.md', extract_extensions := 'wikilinks, tags');
-- Flavor shortcut: 'obsidian' expands to wikilinks + tags
SELECT wikilinks, tags FROM read_markdown('vault/**/*.md', extract_extensions := 'obsidian');
-- Works on the section / block readers too — extracted per-row from each section's / block's content
SELECT title, len(wikilinks) AS n
FROM read_markdown_sections('vault/**/*.md', extract_extensions := 'wikilinks');Available tokens: wikilinks, tags, and the obsidian flavor (= both). Unknown tokens raise an error.
Limitations (v1):
- The wikilink extractor is per-line, so a wikilink target spanning a line break is not matched.
md_extract_wikilinksdoes not currently skip fenced code blocks (md_extract_tagsdoes). A[[…]]inside a code fence will still be extracted.- In the per-section and per-block paths, cmark strips fence markers from its plaintext content, so a
#taginside a fenced code block of a section/block will be extracted (per-fileread_markdownsees the raw bytes and honors fences correctly). - Tokens in
extract_extensionsare split only on commas (not whitespace); a token with internal whitespace is rejected as unknown.
-
md_to_html(markdown)- Convert markdown content to HTML -
md_to_text(markdown)- Convert markdown to plain text (useful for full-text search) -
md_valid(markdown)- Validate markdown content and return boolean -
md_stats(markdown, [exact])- Get document statistics (word count, reading time, etc.). By defaultheading_count,code_block_countandlink_countcome from line/character scanners, which count a[text](target)match anywhere — including images, code spans and fenced code — and miss reference links, autolinks, setext headings, indented code blocks and~~~fences. Passtrueas the second argument to take those three counts from cmark's AST instead, so they agree withmd_extract_linksandmd_extract_code_blocks. The default is unchanged for compatibility;word_count,char_count,line_countandreading_time_minutesare textual and identical either way.SELECT (md_stats(content)).link_count, -- scanner: counts images and code samples (md_stats(content, true)).link_count -- cmark: real links only, references and autolinks included FROM read_markdown('docs/*.md');
-
md_extract_metadata(markdown)- Extract frontmatter asMAP(VARCHAR, VARCHAR). This is a lightweight line-split key/value reader (each line split on the first:, or the first=inside a+++TOML block), not a full YAML or TOML parser — nested maps, lists, and multiline scalars are not interpreted. For real YAML — nested maps, lists,|/>blocks — use theyamlextension'sread_yaml_frontmatter; see Frontmatter Handling. -
md_extract_frontmatter(markdown)- Extract the raw frontmatter block (the text between the---or+++fences) asVARCHAR, orNULLwhen there is no frontmatter. Composes withduckdb_yamlfor real YAML parsing without this extension carrying a YAML parser: e.g.SELECT yaml(md_extract_frontmatter(content)). -
md_extract_section(markdown, section_id, [include_subsections])- Extract specific section by ID. Passtrueas the third argument to include all nested content (full mode); default is minimal mode. -
md_extract_sections(markdown, [min_level, max_level, content_mode])- Extract all sections as a list. Supports optional level filtering and content_mode ('minimal', 'full', 'smart'). -
md_section_breadcrumb(markdown, section_id)- Generate breadcrumb path for a section (returns "Title1 > Title2 > Title3" format) -
value_to_md(value)- Convert any value to markdown representation
Where the block starts and stops. The opening fence must be a line containing exactly
--- (YAML) or +++ (Hugo's TOML), trailing spaces or tabs allowed, as the very first line
of the document; a leading UTF-8 BOM is skipped. A line that merely starts with three
delimiter characters — ----, ---foo — is not a fence at either end, so a document opening
with a thematic break is read as having no frontmatter rather than swallowing the text after
it, and a ---- rule further down does not close a block early and splice its surplus -
into the body. A --- block is closed by the next --- or ... line at column 0 (... is
YAML's document-end marker: it closes, it never opens); a +++ block only by +++. An
empty block — --- immediately followed by ---, Jekyll's idiom — is recognised and yields
no metadata rather than two thematic breaks. These are the same rules the yaml extension's
read_yaml_frontmatter applies, so the two extensions agree about where a given document's
frontmatter begins and ends.
This extension does not parse YAML. Frontmatter — the block between the leading ---
delimiters — is read as flat key: value pairs: each line is split on its first :,
both halves are whitespace-trimmed, and a pair of surrounding double quotes is stripped from
the value. The result is typed MAP(VARCHAR, VARCHAR).
That is a deliberate design choice, not an approximation waiting to be upgraded. Keeping a
YAML parser out of this extension is what lets it accept arbitrary untrusted documents
without inheriting a YAML implementation's parsing surface (billion-laughs style alias
expansion, custom tags, and so on). Markdown parsing goes to cmark-gfm; YAML parsing goes to
the yaml extension; this extension does neither by hand.
What that means in practice. Given this frontmatter:
---
title: My Post
tags:
- duckdb
- markdown
author:
name: Ada
email: ada@example.com
summary: |
A multiline
summary.
quoted: "double quoted"
single: 'single quoted'
url: https://example.com/a:b
---md_extract_metadata returns:
| key | value | why |
|---|---|---|
title |
My Post |
flat pair, as expected |
tags |
(empty string) | the sequence below it is not read; tags just has no value |
author |
(empty string) | the nested map is flattened away |
name |
Ada |
a nested key is hoisted to the top level, where it can collide |
email |
ada@example.com |
split on the first :, so the rest of the line survives |
summary |
| |
the block-scalar indicator is taken as the literal value |
quoted |
double quoted |
a paired " is stripped |
single |
'single quoted' |
single quotes are not stripped |
url |
https://example.com/a:b |
first-: split keeps the remainder intact |
Lines with no : at all — - duckdb, - markdown, and the body of the | block — produce
no entry and are silently dropped. A sequence of mappings (- name: Ada) does produce an
entry, under the key - name.
So: nested maps, lists, multiline |/> block scalars, single-quoted values, and anchors
or aliases are not interpreted. Flat key: value documents — the overwhelming majority of
blog/Obsidian/Hugo frontmatter — come through exactly right.
If you need real YAML, use the yaml extension. It ships read_yaml_frontmatter, which
reads Markdown files and parses their frontmatter with an actual YAML parser:
INSTALL yaml FROM community;
LOAD yaml;
-- Fully typed frontmatter fields, nested structures included
SELECT title, tags FROM read_yaml_frontmatter('posts/*.md');
-- Keep the whole frontmatter document as a YAML/JSON-navigable value
SELECT frontmatter FROM read_yaml_frontmatter('posts/*.md', as_yaml_objects := true);
-- Frontmatter plus the Markdown body in one pass
SELECT title, content FROM read_yaml_frontmatter('posts/*.md', content := true);read_yaml_frontmatter takes ANY, so a single path, a glob, or a LIST of either all work,
and it accepts the named parameters as_yaml_objects, content, and filename. See
duckdb_yaml.
The two extensions compose in both directions:
-- markdown extension finds the documents and the body; yaml extension types the frontmatter
SELECT y.title, y.tags, md_stats(m.content).word_count
FROM read_yaml_frontmatter('posts/*.md', filename := true) y
JOIN read_markdown('posts/*.md', filename := true) m ON m.filename = y.filename;
-- or hand a single raw block to the YAML parser yourself
SELECT yaml(md_extract_frontmatter(content)) FROM read_markdown('posts/*.md');Use md_extract_metadata when you want a cheap flat MAP and your frontmatter is flat.
Use read_yaml_frontmatter when the frontmatter is real YAML.
Convert document blocks to/from Markdown. These functions work with the duck_block structure for in-memory document transformations:
duck_block_to_md(block)- Convert a single block or inline element to Markdown stringduck_blocks_to_md(blocks[])- Convert a list of blocks to a complete Markdown documentduck_blocks_to_sections(blocks[])- Convert blocks to a list of sections with hierarchy
duck_block structure:
STRUCT(
kind VARCHAR, -- 'block', 'inline' or 'value' (metadata)
element_type VARCHAR, -- 'heading', 'paragraph', 'bold', 'link', etc.
content VARCHAR, -- Text content
level INTEGER, -- Structural depth, minimum 1 (a heading's rank is attributes['heading_level'])
encoding VARCHAR, -- 'text', 'json' (tables), 'yaml'/'toml' (frontmatter)
attributes MAP(VARCHAR, VARCHAR),-- Element metadata (heading_level, language, href, etc.)
element_order INTEGER -- Position in sequence
)Note: For headings, the H1-H6 level is stored in attributes['heading_level'] (preferred). If not present, the level field is used as a fallback.
Supported inline types: text, bold/strong, italic/em, code, link, image, strikethrough/del, linebreak/br, math, superscript/sup, subscript/sub
-- Convert blocks back to markdown
SELECT duck_blocks_to_md(list(b ORDER BY element_order))
FROM read_markdown_blocks('source.md') b;
-- Build document programmatically
SELECT duck_blocks_to_md([
{kind: 'block', element_type: 'heading', content: 'Title', level: 1, encoding: 'text', attributes: MAP{}, element_order: 0},
{kind: 'block', element_type: 'paragraph', content: 'Body text.', level: NULL, encoding: 'text', attributes: MAP{}, element_order: 1}
]);
-- Compose inline elements
SELECT duck_blocks_to_md([
{kind: 'inline', element_type: 'text', content: 'Check out ', level: NULL, encoding: 'text', attributes: MAP{}, element_order: 0},
{kind: 'inline', element_type: 'link', content: 'our docs', level: NULL, encoding: 'text', attributes: MAP{'href': 'https://example.com'}, element_order: 1},
{kind: 'inline', element_type: 'text', content: ' for details.', level: NULL, encoding: 'text', attributes: MAP{}, element_order: 2}
]);
-- Returns: 'Check out [our docs](https://example.com) for details.'
-- Convert blocks to sections
SELECT s.section_id, s.level, s.title
FROM (
SELECT unnest(duck_blocks_to_sections(list(b ORDER BY element_order))) as s
FROM read_markdown_blocks('doc.md') b
);-- Convert markdown to HTML for web display
SELECT md_to_html(content) as html_content
FROM read_markdown('README.md');
-- Get document statistics
SELECT
filename,
md_stats(content).word_count as words,
md_stats(content).reading_time_minutes as reading_time
FROM read_markdown('docs/**/*.md');
-- Extract and access frontmatter metadata fields
SELECT
filename,
md_extract_metadata(content)['title'] as title,
md_extract_metadata(content)['author'] as author,
md_extract_metadata(content) as all_metadata
FROM read_markdown('docs/**/*.md')
WHERE cardinality(md_extract_metadata(content)) > 0;
-- Validate markdown content
SELECT filename, md_valid(content::varchar) as is_valid
FROM read_markdown('**/*.md')
WHERE NOT md_valid(content::varchar);-- Default 'minimal' mode: each section has only its immediate content
SELECT section_id, title, length(content) as content_length
FROM read_markdown_sections('docs/guide.md')
WHERE level = 1;
-- 'full' mode: sections include all nested subsections
SELECT section_id, title, length(content) as content_length
FROM read_markdown_sections('docs/guide.md', content_mode := 'full')
WHERE level = 1;
-- 'smart' mode: adaptive - includes small subsections, summarizes large ones
SELECT section_id, title, content
FROM read_markdown_sections('docs/guide.md', content_mode := 'smart')
WHERE level = 1;
-- Fragment syntax: get a specific section and its descendants
SELECT section_id, title, level
FROM read_markdown_sections('README.md#installation');
-- Limit parsing depth: only top 2 levels
SELECT section_id, level, title
FROM read_markdown_sections('docs/**/*.md', max_depth := 2);
-- Extract section with subsections using scalar function
SELECT md_extract_section(content, 'api-reference', true) as full_section
FROM read_markdown('docs/api.md');
-- Extract section without subsections (minimal)
SELECT md_extract_section(content, 'api-reference', false) as minimal_section
FROM read_markdown('docs/api.md');
-- Use section_path for hierarchical navigation
SELECT section_path, title
FROM read_markdown_sections('docs/**/*.md')
WHERE section_path LIKE 'api-reference/%';Export query results to Markdown files. Three modes support different use cases:
| Mode | Use Case | Input Columns |
|---|---|---|
table (default) |
Export any query as a markdown table | Any columns |
document |
Reconstruct markdown from sections | level, title, content |
blocks / duck_block |
Round-trip block-level representation | kind, element_type, content, level, encoding, attributes |
Export any query result as a formatted Markdown table with automatic column alignment:
-- Basic table export
COPY (SELECT * FROM my_table) TO 'output.md' (FORMAT MARKDOWN);
-- With options
COPY my_table TO 'output.md' (FORMAT MARKDOWN,
header true, -- Include header row (default: true)
escape_pipes true, -- Escape | characters (default: true)
escape_newlines true, -- Convert newlines to <br> (default: true)
null_value 'N/A' -- Custom NULL representation (default: empty)
);Output:
| id | name | score |
|---:|---|---:|
| 1 | Alice | 95.5 |
| 2 | Bob | 87.0 |Alignment is automatic: numeric columns are right-aligned, text is left-aligned, booleans are centered.
Reconstruct Markdown documents from structured section data. This complements read_markdown_sections for round-trip document processing:
-- Create sections
CREATE TABLE sections (level INTEGER, title VARCHAR, content VARCHAR);
INSERT INTO sections VALUES
(1, 'Introduction', 'Welcome to the guide.'),
(2, 'Getting Started', 'First steps here.'),
(1, 'Conclusion', 'Thanks for reading!');
-- Export as document
COPY sections TO 'guide.md' (FORMAT MARKDOWN, markdown_mode 'document');Output:
# Introduction
Welcome to the guide.
## Getting Started
First steps here.
# Conclusion
Thanks for reading!Document Mode Options:
COPY sections TO 'doc.md' (FORMAT MARKDOWN,
markdown_mode 'document',
level_column 'level', -- Column with heading level 1-6 (default: 'level')
title_column 'title', -- Column with heading text (default: 'title')
content_column 'content', -- Column with section body (default: 'content')
frontmatter 'title: My Doc
author: Me', -- Optional frontmatter block, emitted verbatim
blank_lines 1 -- Blank lines between sections (default: 1)
);Level 0 as Frontmatter:
Rows with level = 0 are rendered as frontmatter blocks (the content is written between --- delimiters verbatim; it is not validated as YAML):
INSERT INTO doc VALUES (0, '', 'title: Generated Doc');
INSERT INTO doc VALUES (1, 'Body', 'Main content');
COPY doc TO 'output.md' (FORMAT MARKDOWN, markdown_mode 'document');Output:
---
title: Generated Doc
---
# Body
Main contentExport block-level document representation for full-fidelity round-trips. This mode complements read_markdown_blocks() and supports both block and inline elements:
-- Read blocks from a file
CREATE TABLE blocks AS
SELECT kind, element_type, content, level, encoding, attributes
FROM read_markdown_blocks('source.md');
-- Modify blocks
UPDATE blocks SET content = upper(content) WHERE element_type = 'heading';
-- Write back to markdown
COPY blocks TO 'output.md' (FORMAT MARKDOWN, markdown_mode 'blocks');Blocks Mode Options:
COPY blocks TO 'doc.md' (FORMAT MARKDOWN,
markdown_mode 'blocks',
kind_column 'kind', -- Column holding the kind (default: 'kind')
element_type_column 'element_type',-- Column with element type (default: 'element_type')
content_column 'content', -- Column with block content (default: 'content')
level_column 'level', -- Column with level/depth (default: 'level')
encoding_column 'encoding', -- Column with encoding type (default: 'encoding')
attributes_column 'attributes' -- Column with attributes map (default: 'attributes')
);Block Rendering (kind = 'block'):
heading- Rendered as# Title(level determines # count)paragraph- Plain text with blank linescode- Fenced code blocks with language fromattributes['language']blockquote- Prefixed with>list- structural: items arelist_itemchildren (usesattributes['list_type'], with the legacyorderedboolean accepted as a fallback)table- JSON object rendered as markdown tablehr- Horizontal rule---frontmatter- Raw block between---delimiters (emitted verbatim, not YAML-validated)
Inline Rendering (kind = 'inline'):
bold/strong-**text**italic/em-*text*code-`text`link-[text](href "title")text- Plain text (no formatting)- Plus:
strikethrough,superscript,subscript,math, etc.
Inline elements are concatenated without trailing newlines. When transitioning from inline to block elements, proper paragraph breaks are automatically inserted.
Complete workflow reading, transforming, and writing markdown:
-- Read sections from source document
CREATE TABLE my_sections AS
SELECT level, title, content
FROM read_markdown_sections('source.md', content_mode := 'minimal');
-- Transform content (e.g., add prefix to all headings)
UPDATE my_sections SET title = 'Chapter: ' || title WHERE level = 1;
-- Write back to markdown
COPY my_sections TO 'output.md' (FORMAT MARKDOWN, markdown_mode 'document');
-- Or use blocks for full-fidelity round-trip
COPY (
SELECT kind, element_type, content, level, encoding, attributes
FROM read_markdown_blocks('source.md')
) TO 'copy.md' (FORMAT MARKDOWN, markdown_mode 'blocks');Analyze code documentation across entire repositories:
-- Find all Python examples in documentation
SELECT filename, unnest.code, unnest.line_number
FROM read_markdown('docs/**/*.md') docs,
UNNEST(md_extract_code_blocks(docs.content))
WHERE unnest.language = 'python';
-- Audit external links in documentation
SELECT unnest.url, count(*) as usage_count
FROM read_markdown('**/*.md') docs,
UNNEST(md_extract_links(docs.content))
WHERE unnest.url LIKE 'http%'
GROUP BY unnest.url
ORDER BY usage_count DESC;Evaluate documentation completeness and quality:
-- Calculate content richness scores
SELECT
filename,
len(md_extract_code_blocks(content)) * 3 +
len(md_extract_links(content)) * 1 +
len(md_extract_images(content)) * 2 as richness_score
FROM read_markdown('docs/**/*.md')
ORDER BY richness_score DESC;Create searchable knowledge bases from documentation:
-- Create searchable documentation index
CREATE TABLE docs AS
SELECT
title, level, content,
row_number() OVER (ORDER BY title) as section_id
FROM read_markdown_sections('**/*.md', include_content := true);
-- Create full-text search index using plain text conversion
PRAGMA create_fts_index('docs', 'section_id', 'md_to_text(content)');
-- Search documentation
SELECT title, substring(md_to_text(content), 1, 200) as preview
FROM docs
WHERE md_to_text(content) ILIKE '%memory optimization%'
ORDER BY title;A vault is just a folder of .md files. With extract_extensions := 'obsidian', you can build a backlink graph and tag rollups across the whole vault in SQL:
-- Backlink edges across a vault: (source_file, target_note)
WITH wl AS (
SELECT filename AS source, unnest(wikilinks) AS w
FROM read_markdown('vault/**/*.md', include_filepath := true, extract_extensions := 'wikilinks')
)
SELECT source, w.target AS target_note, w.is_embed, w.line_number
FROM wl;
-- Orphan notes (no incoming wikilinks)
WITH edges AS (
SELECT regexp_extract(filename, '([^/]+)\.md$', 1) AS note,
unnest(wikilinks).target AS target
FROM read_markdown('vault/**/*.md', include_filepath := true, extract_extensions := 'wikilinks')
)
SELECT DISTINCT note FROM edges
WHERE note NOT IN (SELECT target FROM edges);
-- Tag usage across the vault
SELECT t.tag, count(*) AS uses
FROM read_markdown('vault/**/*.md', extract_extensions := 'tags'),
UNNEST(tags) AS t
GROUP BY t.tag
ORDER BY uses DESC;
-- Per-section tag/wikilink density (which sections are link-rich?)
SELECT title, len(wikilinks) + len(tags) AS link_density
FROM read_markdown_sections('vault/**/*.md', extract_extensions := 'obsidian')
ORDER BY link_density DESC
LIMIT 20;The extension includes comprehensive glob pattern support across different file systems:
-- Basic patterns
SELECT * FROM read_markdown('docs/*.md');
SELECT * FROM read_markdown('**/*.markdown');
-- Recursive directory scanning
SELECT * FROM read_markdown('documentation/**/*.md');
-- Multiple patterns
SELECT * FROM read_markdown(['README.md', 'docs/**/*.md', 'examples/**/*.md']);
-- Remote file systems (S3, etc.)
SELECT * FROM read_markdown('s3://bucket/docs/*.md');Supported patterns:
*.md,**/*.markdown- Standard glob patternsdocs/**/*.md- Recursive directory scanning- Mixed lists combining files and glob patterns
- Remote file systems with graceful degradation
All functions return structured data using LIST<STRUCT> types:
-- Code blocks
LIST<STRUCT(language VARCHAR, code VARCHAR, line_number BIGINT, info_string VARCHAR)>
-- Links (is_reference=true for reference-style links like [text][ref])
LIST<STRUCT(text VARCHAR, url VARCHAR, title VARCHAR, is_reference BOOLEAN, line_number BIGINT)>
-- Images
LIST<STRUCT(alt_text VARCHAR, url VARCHAR, title VARCHAR, line_number BIGINT)>
-- Table rows
LIST<STRUCT(table_index BIGINT, row_type VARCHAR, row_index BIGINT, column_index BIGINT, cell_value VARCHAR, line_number BIGINT, num_columns BIGINT, num_rows BIGINT)>
-- Tables JSON
LIST<STRUCT(table_index BIGINT, line_number BIGINT, num_columns BIGINT, num_rows BIGINT, headers VARCHAR[], table_data VARCHAR[][])>
-- Blocks (from read_markdown_blocks)
(kind VARCHAR, element_type VARCHAR, content VARCHAR, level INTEGER, encoding VARCHAR, attributes MAP(VARCHAR, VARCHAR), element_order INTEGER)Use with UNNEST() to flatten into rows or len() to count elements.
The extension defines a MARKDOWN type (alias: md) that automatically casts to/from VARCHAR:
-- Explicit casting
SELECT '# Hello World'::markdown;
SELECT '# Hello World'::md;
-- Automatic casting - these all work seamlessly
SELECT md_to_html('# Hello World'); -- VARCHAR automatically cast to MARKDOWN
SELECT md_stats('# Test\nContent'); -- VARCHAR automatically cast to MARKDOWN
SELECT content::varchar FROM read_markdown('README.md'); -- MARKDOWN to VARCHAR
-- Type checking
SELECT typeof('# Hello World'::markdown); -- Returns 'md'All markdown functions accept both VARCHAR and MARKDOWN types through automatic casting, making the API flexible and easy to use.
The extension is designed for high-performance document processing:
- 4,000+ sections/second processing rate on typical hardware
- Memory efficient streaming processing
- Parallel safe for concurrent query execution
- Cross-platform robust glob support including remote file systems
Real-world benchmark: Processing 287 Markdown files (2,699 sections, 1,137 code blocks, 1,174 links) in 603ms.
✅ Available (v1.3.6):
- Complete file reading functions (
read_markdown,read_markdown_sections,read_markdown_blocks) with full parameter support - COPY TO markdown with table, document, and blocks modes (including inline element support)
- All 5 extraction functions (
md_extract_code_blocks,md_extract_links,md_extract_images,md_extract_table_rows,md_extract_tables_json) - Document processing functions (
md_to_html,md_to_text,md_valid,md_stats,md_extract_metadata,md_extract_section,md_section_breadcrumb) - Block-level document representation with
read_markdown_blocks()andmarkdown_mode 'blocks'/'duck_block' - Duck block conversion functions (
duck_block_to_md,duck_blocks_to_md,duck_blocks_to_sections) - Unified duck_block shape with
kindfield for block/inline differentiation - Content modes for flexible section extraction:
'minimal'(default),'full','smart' - Fragment syntax for filtering sections:
'file.md#section-id' - Section hierarchy with
section_pathcolumn for navigation - Reference link detection in
md_extract_links(is_referencefield) - Advanced section filtering and processing options (min/max level, max_depth, content inclusion, etc.)
- Frontmatter metadata as
MAP(VARCHAR, VARCHAR)for easy field access - Replacement scan support for table-like syntax (
FROM '*.md') - MARKDOWN type with automatic VARCHAR casting
- Cross-platform support (Linux, macOS, Windows, WebAssembly)
- Robust glob pattern support for local and remote file systems
- High-performance content processing (4,000+ sections/second)
- Comprehensive parameter system for flexible file processing
- Full test suite with 1102 passing assertions across 20 test files
🗓️ Future Roadmap:
- Document interchange format for cross-extension compatibility (HTML, XML, etc.)
- Custom renderer integration for specialized markdown flavors
- Streaming parser optimizations for very large documents (>100MB)
- Advanced query optimization for document search workloads
- cmark-gfm: GitHub Flavored Markdown parsing library
- DuckDB: Version 1.0.0 or later
# Clone with dependencies
git clone --recurse-submodules https://github.com/teaguesterling/duckdb_markdown
cd duckdb_markdown
# Build extension
make
# Run tests
make testComprehensive test suite with 1102 passing assertions across 20 test files:
- Functionality tests: All extraction functions with edge cases
- Block-level tests: Round-trip parsing and rendering with inline element support
- Performance tests: Large-scale document processing
- Cross-platform tests: File system compatibility scenarios
- Integration tests: Complex queries and real-world usage patterns
Contributions welcome! The extension provides a solid foundation for Markdown analysis with room for enhancements:
- File reading functions: Complete the table function implementations
- Metadata extraction: Frontmatter parsing and document statistics
- Performance optimizations: Streaming improvements for very large documents
- Advanced features: Custom renderer integration and streaming parser optimizations
The extension fully supports WebAssembly environments (DuckDB-WASM in browsers). All markdown functions work in the browser:
// Load the extension in DuckDB-WASM
await conn.query("LOAD 'markdown.duckdb_extension.wasm'");
// Register a markdown file
await db.registerFileText('doc.md', '# Hello\n\nWorld');
// Query markdown content
const result = await conn.query("SELECT * FROM read_markdown('doc.md')");Important: Use the extension version that matches your duckdb-wasm version:
- duckdb-wasm 1.32.0 → Use
markdown-v1.4.3-extension-wasm_eh - duckdb-wasm latest → Use
markdown-main-extension-wasm_eh
For technical details on how WASM support was implemented (including cmark-gfm static linking and function pointer compatibility), see docs/WASM.md.
MIT License - see LICENSE file for details.