Reading MS Project XML Exports with Python
A Microsoft Project schedule saved as XML is a well-formed MSPDI document, but three details trip up nearly every first-pass parser. This page solves one precise problem: how to read an MSPDI export with the standard-library xml.etree.ElementTree so that namespace-qualified tags resolve, PT#H#M#S durations decode to working days, and bare local timestamps become timezone-aware datetime values — yielding a typed task record rather than a bag of strings. Get any one of those wrong and the failure is silent: an unqualified tag lookup returns an empty list from a full schedule, a duration parsed as elapsed time understates every task, and a naive timestamp corrupts float math the instant it meets a timezone-aware activity from another tool. This reader is the extraction front end for MS Project schedule parsing; it produces the normalized fields that stage then binds to a WBS node. It targets Python automation builders who need MSPDI reads that behave identically across every export a scheduling team hands them.
Key Rules and Specification
A reliable MSPDI reader enforces the schema’s quirks at the boundary rather than hoping the export is clean. The rules below govern every read:
- The default namespace is not optional. MSPDI declares
xmlns="http://schemas.microsoft.com/project"on the root<Project>.ElementTreequalifies every tag with that URI, so a lookup for the bare tag"Task"matches nothing. Register the namespace under a prefix and queryp:Task. - Durations are working-time ISO 8601.
PT8H0M0Sis one eight-hour working day, not a third of a calendar day. Divide the parsed hours by the calendar’sHoursPerDay(usually 8) to get working days; never treat the value as elapsed clock time. - Timestamps are bare local.
<Start>and<Finish>are written as2026-07-15T08:00:00with no offset. Stamp each with the project’s IANA time zone so the value is timezone-aware before it leaves the reader, exactly as the ISO 8601 date and time standard intends. - UID 0 is a phantom. Task
UID0 is the reserved project-summary row with no scope. Skip it. The<ID>field is the 1-based ordinal that field staff recognize. - Absent children are
None, not empty strings. An optional element such as<Notes>may be missing entirely; a namespace-safe accessor must return a default rather than raising anAttributeErroronchild.text.
| MSPDI element | Raw form | Reader output | Coercion |
|---|---|---|---|
Task/UID |
"12" |
int id, UID 0 skipped |
int(...) |
Task/Name |
"Erect steel" |
str |
strip, default label |
Task/Duration |
PT16H0M0S |
Decimal("2.000") days |
÷ HoursPerDay |
Task/Start |
2026-07-15T08:00:00 |
tz-aware datetime |
stamp project zone |
Task/Finish |
2026-07-17T17:00:00 |
tz-aware datetime |
stamp project zone |
Production Code Example
The reader below binds the namespace once, iterates real tasks, and yields a frozen, typed ParsedTask. Duration decoding and timestamp coercion are pure functions so a broker retry reads the identical values. The ElementTree API used here is the standard-library one documented in the Python xml.etree.ElementTree reference.
from __future__ import annotations
import logging
import re
from datetime import datetime
from decimal import Decimal
from typing import Iterator
from xml.etree.ElementTree import Element, parse
from zoneinfo import ZoneInfo
from pydantic import BaseModel, ConfigDict, Field, field_validator
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)-8s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("mspdi.reader")
# MSPDI declares this as the default namespace on <Project>; ElementTree qualifies
# EVERY tag with it, so all find/findall paths must go through the prefix.
MSPDI_NS = {"p": "http://schemas.microsoft.com/project"}
# MS Project writes durations as ISO 8601 working-time: PT<h>H<m>M<s>S.
_DURATION_RE = re.compile(r"^PT(\d+)H(\d+)M(\d+)S$")
class ParsedTask(BaseModel):
"""A typed MSPDI task row, before WBS binding."""
model_config = ConfigDict(frozen=True)
uid: int = Field(ge=1) # UID 0 (project summary) is never emitted
ordinal_id: int = Field(ge=1) # the 1-based <ID> field staff reference
name: str = Field(min_length=1, max_length=240)
outline_number: str
is_summary: bool
duration_days: Decimal = Field(ge=0)
start: datetime
finish: datetime
@field_validator("start", "finish")
@classmethod
def require_tz_aware(cls, v: datetime) -> datetime:
if v.tzinfo is None:
raise ValueError("timestamps must be timezone-aware")
return v
def _text(node: Element, path: str, default: str | None = None) -> str | None:
"""Namespace-safe child text lookup that tolerates absent/empty elements."""
child = node.find(path, MSPDI_NS)
if child is None or child.text is None or not child.text.strip():
return default
return child.text.strip()
def duration_to_days(raw: str, hours_per_day: Decimal = Decimal("8")) -> Decimal:
"""Decode a PT#H#M#S working-time duration into working days. Pure, idempotent."""
match = _DURATION_RE.match(raw.strip())
if match is None:
raise ValueError(f"Unrecognized MSPDI duration: {raw!r}")
hours, minutes, seconds = (Decimal(g) for g in match.groups())
total_hours = hours + (minutes / Decimal("60")) + (seconds / Decimal("3600"))
return (total_hours / hours_per_day).quantize(Decimal("0.001"))
def to_aware(raw: str, project_tz: ZoneInfo) -> datetime:
"""Parse a bare MSPDI local timestamp and make it timezone-aware."""
parsed = datetime.fromisoformat(raw) # 2026-07-15T08:00:00 — no offset
if parsed.tzinfo is not None:
return parsed
return parsed.replace(tzinfo=project_tz)
def read_tasks(
xml_path: str,
project_tz: ZoneInfo,
hours_per_day: Decimal = Decimal("8"),
) -> Iterator[ParsedTask]:
"""Yield one typed ParsedTask per real MSPDI task; log and skip bad rows."""
root = parse(xml_path).getroot()
tasks_block = root.find("p:Tasks", MSPDI_NS)
if tasks_block is None:
raise ValueError("No <Tasks> block; file is not an MSPDI schedule export")
for task in tasks_block.findall("p:Task", MSPDI_NS):
uid = _text(task, "p:UID")
if uid is None or uid == "0":
continue # missing UID, or the reserved project-summary row
try:
yield ParsedTask(
uid=int(uid),
ordinal_id=int(_text(task, "p:ID", default="0") or "0"),
name=_text(task, "p:Name", default="(unnamed task)") or "(unnamed task)",
outline_number=_text(task, "p:OutlineNumber", default="") or "",
is_summary=(_text(task, "p:Summary", default="0") == "1"),
duration_days=duration_to_days(
_text(task, "p:Duration") or "PT0H0M0S", hours_per_day
),
start=to_aware(_text(task, "p:Start") or "", project_tz),
finish=to_aware(_text(task, "p:Finish") or "", project_tz),
)
except (ValueError, TypeError) as exc:
logger.warning("Skipping task UID %s: %s", uid, exc)
continue
if __name__ == "__main__":
tz = ZoneInfo("America/New_York")
for parsed in read_tasks("schedule.xml", tz):
flag = "summary" if parsed.is_summary else "leaf"
print(f"{parsed.uid:>4} {flag:<8} {parsed.duration_days!s:>7}d {parsed.name}")Common Mistakes and Gotchas
Unqualified tag lookups that silently find nothing. The single most common MSPDI bug is calling root.findall("Task") or task.find("Duration") without the namespace map. Because ElementTree prefixes every tag with the full URI, the bare name never matches and the reader reports zero tasks on a complete schedule — with no exception to signal the mistake. Always pass MSPDI_NS and prefix the path (p:Task), and consider asserting the task count is non-zero before proceeding so an empty read fails loudly instead of continuing.
Treating the duration as elapsed clock time. Parsing PT8H0M0S and dividing by 24 to get “days” understates the task to a third of a day. MSPDI durations are working-time; the correct divisor is the calendar’s HoursPerDay. Reading the calendar’s <HoursPerDay> once and threading it through duration_to_days keeps every task on the same working-day basis the schedule was planned in.
Leaving timestamps naive. datetime.fromisoformat("2026-07-15T08:00:00") returns a naive value because MSPDI omits the offset. If that value reaches any comparison against a timezone-aware activity — say a Primavera P6 record already normalized to UTC — Python either raises TypeError: can't compare offset-naive and offset-aware datetimes or, worse, compares local wall-clock to UTC and reports a phantom multi-hour drift. Stamp the project zone at read time and never emit a naive timestamp.
Where This Fits in the Pipeline
This reader is the extraction front end of MS Project schedule parsing, itself one arm of the broader schedule sync integration subsystem. It stops at typed, normalized fields; the next stage, mapping MS Project tasks to WBS elements, takes each ParsedTask and resolves its outline_number to a canonical PROJ-NNN-DIV-NN node before the record becomes a full ScheduleActivity. Keeping reading and binding as separate stages means an export-format quirk never contaminates the WBS crosswalk, and a crosswalk change never forces a re-read of the XML. Downstream, the emitted activities feed critical-path and baseline comparison exactly as the Primavera P6 records do, because both arrive in the identical contract.
Frequently Asked Questions
Why does findall("Task") return an empty list from a valid file?
Because MSPDI tags are namespace-qualified. ElementTree reads xmlns="http://schemas.microsoft.com/project" off the root and prefixes every tag with that URI internally, so the bare name "Task" never matches. Register the namespace under a prefix ({"p": "http://schemas.microsoft.com/project"}) and query p:Task, or the reader reports zero tasks on a full schedule with no error to signal why.
How do I convert a PT8H0M0S duration to days?
Match the PT<h>H<m>M<s>S pattern, sum hours plus minutes over 60 plus seconds over 3600, and divide by the calendar’s HoursPerDay. On a standard 8-hour calendar PT8H0M0S becomes exactly one working day and PT16H0M0S becomes two. The value is working-time, so dividing by 24 is always wrong.
Why must the timestamps be made timezone-aware?
MSPDI writes <Start> and <Finish> as bare local datetimes with no offset. Left naive, they cannot be compared against timezone-aware activities from another scheduling tool without either a TypeError or a silent wall-clock-versus-UTC drift. Stamping the project’s IANA zone at read time makes every timestamp unambiguous before it leaves the reader.
Should I use iterparse for large MSPDI files?
For very large schedules, iterparse streams elements and lowers peak memory, and you can clear each <Task> after reading it. The namespace, duration, and timezone rules are identical either way — the qualified tag names and the same pure normalizers apply. Start with parse for clarity; switch to iterparse only when a profile shows the tree is the memory bottleneck.
What happens to a task with a malformed duration or date?
The reader logs a warning keyed on the task UID and skips that single row rather than aborting the whole file. Isolating the failure per row means one corrupt export line never discards the hundreds of clean tasks around it, and the logged UID gives a scheduler an exact record to correct upstream.
Related
- MS Project Schedule Parsing
- Mapping MS Project Tasks to WBS Elements
- Primavera P6 Export Parsing
- WBS Mapping Strategies
- Schema Validation Rules
← Back to MS Project Schedule Parsing