Parsing Org-mode Files with Python
Table of Contents
1. Overview
Org-mode files are plain text, but their semantics are not. A heading carries TODO state, priority, tags, scheduled/deadline timestamps, properties, and a position in the document tree. Parsing this structure manually is error-prone. The orgparse library exposes org documents as a traversable Python object model.
The examples use test data from the orgparse suite, available in orgparse-examples/.
2. Installation
Installed orgparse (0.5.20260926) and ran the snippet: the module has no =__version__= attribute, so =print(orgparse.__version__)= raises AttributeError. Checked the real repo's pyproject.toml (github.com/karlicoss/orgparse) directly: =requires-python = ">=3.10"=, not "3.7+".
orgparse is a pure-Python library with no dependencies.
pip install orgparse
Verify the install:
import importlib.metadata
print(importlib.metadata.version('orgparse'))
orgparse has no __version__ attribute; use importlib.metadata to
check the installed version. The library requires Python 3.10+. No
Emacs installation required.
3. Loading a document
orgparse.load() parses an org file and returns a root node.
import orgparse
# Load from file path
root = orgparse.load('site/research/orgparse-examples/00_simple.org')
# The root node represents the entire document
print(f"Root level: {root.level}") # 0
print(f"Children: {len(root.children)}")
Each node in the tree has:
.level– heading depth (0 for root, 1 for*, 2 for**, etc.).heading– heading text without markup.children– list of child nodes.parent– parent node reference
The root node's level is always 0. Top-level headings are its children.
4. Accessing heading metadata
Ran the example against 00_simple.org (orgparse 0.5.20260926): =.tags= returns inherited tags by default -- confirmed against the library's own docstring ("Tags of this and parent's node") -- the opposite of what the prose below claimed. The "Expected output" block already showed this (Heading 1 carries TAG1+TAG2) but the prose beneath it asserted the reverse. =.shallow_tags= is the accessor for direct-only tags, not a manual =.get_parent()= walk. Also removed the Limitations section's duplicate of the same wrong claim.
Headings carry TODO state, priority, and tags. These attributes are accessible as node properties.
root = orgparse.load('site/research/orgparse-examples/00_simple.org')
# Traverse all nodes
for node in root[1:]: # Skip root
print(f"{'*' * node.level} {node.heading}")
if node.todo:
print(f" TODO: {node.todo}")
if node.priority:
print(f" Priority: {node.priority}")
if node.tags:
print(f" Tags: {node.tags}")
Expected output (excerpt):
#+begin_example
5. Heading 0
TODO: TODO1 Tags: {'TAG1'}
5.1. Heading 1
TODO: TODO2 Tags: {'TAG1', 'TAG2'} #+end_example
Tags are a set that includes this node's own tags plus every ancestor's
– .tags walks up the tree by default, which is why "Heading 1" above
shows both TAG1 (inherited from "Heading 0") and TAG2 (its own). Use
.shallow_tags for only the node's directly-attached tags.
6. Working with timestamps
Ran against 01_attributes.org (orgparse 0.5.20260926): =.start= correctly returns =datetime.date= for date-only scheduled/deadline and =datetime.datetime= for the timed CLOSED entry -- that claim held up. But =node.get_timestamps()= does not "return all timestamps (scheduled, deadline, body text)": its four flags (active/inactive/range/point) default to False, so a bare call returns =[]=; per its own docstring it only scans body text, never scheduled/deadline; and it returns =OrgDate= objects, not "OrgTime".
Org timestamps appear in scheduling metadata (SCHEDULED, DEADLINE,
CLOSED) and inline in body text. orgparse exposes scheduling timestamps
as datetime objects.
import orgparse
from datetime import datetime
root = orgparse.load('site/research/orgparse-examples/01_attributes.org')
for node in root[1:]:
if node.scheduled:
print(f"{node.heading}:")
print(f" Scheduled: {node.scheduled.start}")
if node.deadline:
print(f" Deadline: {node.deadline.start}")
if node.closed:
print(f" Closed: {node.closed.start}")
The .start attribute returns a Python datetime.datetime or datetime.date
object depending on whether the timestamp includes time-of-day.
For inline timestamps in the body, parse node.body manually or use
node.get_timestamps(active=True, inactive=True, range=True, point=True).
All four flags default to False (a bare call returns an empty list),
and the method only scans body text – scheduled/deadline/closed stay
separate fields. It returns a list of OrgDate objects.
7. Extracting properties
Ran against 01_attributes.org (orgparse 0.5.20260926): the actual heading text is "A node with a lot of attributes", not "Heading with attributes", and the real Effort value is the int =70= -- orgparse parses the drawer's "1:10" H:MM syntax into minutes -- not the string "1:20" shown in the note's expected-output example. Confirmed separately with a generic drawer entry that ordinary property values are plain strings; Effort is a special-cased exception.
Org properties live in a :PROPERTIES: drawer under a heading. orgparse
exposes them as a dict.
root = orgparse.load('site/research/orgparse-examples/01_attributes.org')
for node in root[1:]:
if node.properties:
print(f"{node.heading}:")
for key, value in node.properties.items():
print(f" {key}: {value}")
Expected output (excerpt):
A node with a lot of attributes: Effort: 70
Property values are strings for ordinary properties. Effort is a
documented exception – orgparse parses its H:MM duration syntax and
returns an int count of minutes, as shown above (1:10 -> 70).
8. Navigating the tree
The document is a tree. Each node has .parent, .children, and
.get_parent() / .get_children() accessors.
root = orgparse.load('site/research/orgparse-examples/02_tree_struct.org')
# Find a specific node by heading
def find_heading(root, heading_text):
for node in root[1:]:
if node.heading == heading_text:
return node
return None
node = find_heading(root, "Heading 2")
if node:
print(f"Found: {node.heading} (level {node.level})")
print(f"Parent: {node.parent.heading if node.parent else 'None'}")
print(f"Children: {[c.heading for c in node.children]}")
root[1:] iterates depth-first over all nodes except the root. This is
the primary traversal mechanism.
For breadth-first or level-filtered traversal, implement custom traversal:
def nodes_at_level(root, target_level):
return [n for n in root[1:] if n.level == target_level]
root = orgparse.load('site/research/orgparse-examples/02_tree_struct.org')
level2 = nodes_at_level(root, 2)
print(f"Level 2 headings: {[n.heading for n in level2]}")
9. Querying TODO items
A common use case: extract all TODO items with deadlines approaching.
import orgparse
from datetime import datetime, timedelta
root = orgparse.load('site/research/orgparse-examples/03_repeated_tasks.org')
now = datetime.now()
week_ahead = now + timedelta(days=7)
for node in root[1:]:
# Only TODO items
if not node.todo or node.todo == "DONE":
continue
# With deadlines in the next week
if node.deadline and node.deadline.start:
deadline = node.deadline.start
# Convert date to datetime for comparison if needed
if hasattr(deadline, 'date'):
deadline = deadline.date()
print(f"TODO: {node.heading}")
print(f" State: {node.todo}")
print(f" Deadline: {node.deadline.start}")
This pattern generalizes: filter nodes by TODO state, tags, properties, or timestamp predicates.
10. Converting to structured data
Export org documents as JSON for downstream processing.
import orgparse
import json
def node_to_dict(node):
"""Convert an org node to a dict."""
return {
'heading': node.heading,
'level': node.level,
'todo': node.todo,
'tags': list(node.tags) if node.tags else [],
'properties': node.properties,
'body': node.body,
'children': [node_to_dict(c) for c in node.children]
}
root = orgparse.load('site/research/orgparse-examples/00_simple.org')
doc = {
'title': root.heading or 'Untitled',
'children': [node_to_dict(c) for c in root.children]
}
print(json.dumps(doc, indent=2)[:500]) # First 500 chars
This exports the full tree. For flat output (e.g., CSV of TODO items),
iterate root[1:] and collect fields directly.
11. Practical use cases
11.1. Automating weekly reviews
Extract completed tasks from the past week:
from datetime import datetime, timedelta
root = orgparse.load('weekly-tasks.org')
week_ago = datetime.now() - timedelta(days=7)
completed = []
for node in root[1:]:
if node.todo == "DONE" and node.closed:
closed = node.closed.start
if closed >= week_ago:
completed.append(node.heading)
print(f"Completed this week: {len(completed)}")
for task in completed:
print(f" - {task}")
11.2. Generating issue trackers from org files
Convert org TODO items to GitHub issues:
import orgparse
root = orgparse.load('project-tasks.org')
for node in root[1:]:
if node.todo == "TODO":
title = node.heading
body = node.body or "(no description)"
tags = ",".join(node.tags) if node.tags else ""
# gh CLI invocation (pseudo-code)
# subprocess.run(['gh', 'issue', 'create',
# '-t', title, '-b', body, '-l', tags])
print(f"Would create issue: {title}")
11.3. Publishing org to static sites
Extract frontmatter and convert body to Markdown:
import orgparse
root = orgparse.load('post.org')
# Extract document-level properties
title = root.get_property('TITLE', 'Untitled')
date = root.get_property('DATE', '')
print(f"---")
print(f"title: {title}")
print(f"date: {date}")
print(f"---")
print()
print("(body conversion would go here)")
For full Markdown conversion, use pandoc or implement org-to-markdown
manually. orgparse handles the parsing; conversion is a separate concern.
12. Limitations
orgparse is read-only. It does not write org files or preserve formatting. For round-trip editing, use Emacs or a dedicated org writer library.
The library parses structure, not evaluation. Babel source blocks are accessible as strings, but orgparse does not execute them or expand macros.
13. See also
- Annotated examples – test data from the orgparse suite
- orgparse repository – source and documentation
- Org syntax specification – the grammar orgparse implements
- Org-Mode Feed Generation – this site's own org parser, in Clojure
- Org-Mode Include Patterns for Long Research Pages – the structure a parser has to follow