Skip to content

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.

MSPDI read pipeline: namespace, iterate, normalize, yield A left-to-right flow. Stage one parses the MSPDI file and registers the default namespace http://schemas.microsoft.com/project under the prefix p. Stage two iterates namespace-qualified Task elements and skips the reserved UID 0 project-summary row. Stage three decodes each PT#H#M#S working-time duration into working days using HoursPerDay and stamps each bare local timestamp with the project time zone to make it timezone-aware. Stage four yields a typed ParsedTask. A failure in namespace binding or duration and date format diverts that row to a rejected branch below rather than emitting a malformed task. 1 · Parse + bind ns register prefix p → schemas.microsoft.com/project 2 · Iterate tasks findall p:Task skip UID 0 3 · Normalize PT#H#M#S → days local → tz-aware 4 · Yield typed ParsedTask unbound namespace bad duration / naive dt Rejected row logged · never emitted as a task
Reading MSPDI is a four-stage pipe: bind the namespace, iterate real tasks, normalize durations and dates, and yield a typed record — with namespace and format failures routed to a logged rejection rather than a malformed task.

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>. ElementTree qualifies every tag with that URI, so a lookup for the bare tag "Task" matches nothing. Register the namespace under a prefix and query p:Task.
  • Durations are working-time ISO 8601. PT8H0M0S is one eight-hour working day, not a third of a calendar day. Divide the parsed hours by the calendar’s HoursPerDay (usually 8) to get working days; never treat the value as elapsed clock time.
  • Timestamps are bare local. <Start> and <Finish> are written as 2026-07-15T08:00:00 with 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 UID 0 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 an AttributeError on child.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.

← Back to MS Project Schedule Parsing