Skip to content

Parsing Primavera P6 XER Files with Python

The XER file is Primavera P6’s native interchange format, and it looks deceptively like a CSV — until a smart quote in an activity name crashes the reader, an embedded tab splits one row into two, or a new P6 version shifts every column by one. This page solves one precise problem: how to parse an XER export reliably into typed rows without silent data loss, respecting its versioned header, its %T/%F/%R/%E record markers, its tab delimiting, and its Windows-1252 encoding, so that every TASK, PROJWBS, and CALENDAR row survives the trip into a typed record. XER is not one table; it is a stack of relational tables concatenated into a single text file, each announced by a marker line. Get the decoding wrong and the failure is quiet — activity names lose characters, float lands in the wrong column, and nothing raises. This is the reader stage of Primavera P6 export parsing within schedule sync integration, and it targets Python automation builders who need every row to arrive intact and typed.

XER record-marker dispatch to typed rows A top-down diagram. An XER export file, CP1252-encoded and tab-delimited, feeds a csv reader that dispatches on the first cell of each line, the record marker. Four marker chips fan out: percent-T sets the current table such as PROJWBS or TASK, percent-F captures the field header list, percent-R emits a data row, and percent-E marks end of file and stops. The percent-R path passes down to a stage that zips the captured headers with the row cells into a row dict, which is then validated into a typed, WBS-bound ScheduleActivity record. XER export file CP1252 · tab-delimited · ERMHDR first csv.reader over lines dispatch on record marker %T set current table %F capture headers %R emit data row %E end of file · stop zip(headers, cells[1:]) → row dict keyed by field name, not position ScheduleActivity typed · WBS-bound · validated
Each XER line is dispatched on its record marker; only the %R rows, zipped with the header list captured from the preceding %F line, become typed activity records.

Key Rules and Specification

A robust XER reader is a small state machine that reads the file exactly once and enforces the format’s contract at every line:

  • The header line is ERMHDR. The first line of every XER file is an ERMHDR record carrying the export version, date, and originating database. It is not a table; skip it, or read the version if you branch on P6 releases, but never feed it to a table parser.
  • Four record markers govern state. %T <TABLE> opens a table, %F <field…> declares that table’s column headers, %R <value…> is a data row, and %E marks end of file. Any line whose first cell is not one of these is ignored by design.
  • Encoding is Windows-1252, always. P6 writes XER as cp1252, so a UTF-8 read fails on the first smart quote, en dash, or degree sign in an activity name. Decode explicitly as cp1252; never suppress the error, which deletes characters silently.
  • It is tab-delimited, but not tab-split. Activity names and notes routinely contain literal tabs and embedded newlines. Use the csv module with delimiter="\t" and newline="" so quoted or multi-line fields stay whole; a bare str.split("\t") fabricates phantom columns.
  • Key rows by header name, not index. A newer P6 version adds columns, so positional access (cells[7]) silently reads the wrong field after an upgrade. Zip each %R row against the %F headers captured for the current table and access fields by name.
  • %E is authoritative. Stop at the end marker; treat anything after it as absent rather than parsing trailing bytes.
Marker Meaning Reader action
ERMHDR Export version header Read version or skip; never a table row
%T Table declaration Set current table, clear headers
%F Field header list Capture headers for the current table
%R Data row Zip with headers → typed row dict
%E End of file Stop reading

Production Code Example

The reader below streams the file once, dispatches on the record marker, and yields (table_name, row_dict) pairs only for the scheduling tables — nothing is held in memory beyond the current headers. Pattern details for the markers follow the standard Python re module documentation, though the dispatch here needs only exact string equality.

from __future__ import annotations

import csv
import logging
from collections.abc import Iterator
from datetime import datetime, timezone
from pathlib import Path
from typing import Literal

from pydantic import BaseModel, Field, field_validator

logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)-8s | %(message)s")
logger = logging.getLogger("xer_reader")

WANTED_TABLES = frozenset({"PROJWBS", "TASK", "TASKPRED", "CALENDAR"})


def read_xer_tables(path: Path) -> Iterator[tuple[str, dict[str, str]]]:
    """Stream (table_name, row) pairs from an XER export without data loss.

    XER is CP1252, tab-delimited, with ERMHDR then %T/%F/%R/%E markers. Reading
    with csv (not str.split) preserves embedded tabs and newlines inside fields.
    """
    current_table: str | None = None
    headers: list[str] = []

    with path.open("r", encoding="cp1252", newline="") as fh:
        reader = csv.reader(fh, delimiter="\t")
        for cells in reader:
            if not cells:
                continue
            marker = cells[0]
            if marker == "%T":                 # %T <TABLE_NAME>
                current_table = cells[1] if len(cells) > 1 else None
                headers = []
            elif marker == "%F":               # header row for current table
                headers = cells[1:]
            elif marker == "%R":               # data row
                if current_table in WANTED_TABLES and headers:
                    values = cells[1:]
                    if len(values) != len(headers):
                        # Never guess: a length mismatch is corruption, not a row.
                        logger.warning(
                            "row width mismatch in %s: %d headers, %d values",
                            current_table, len(headers), len(values),
                        )
                        continue
                    yield current_table, dict(zip(headers, values))
            elif marker == "%E":               # explicit end of file
                break
            # ERMHDR and unknown markers fall through untouched, by design.

With the stream in hand, building a typed record is a validation step, not more parsing. The ScheduleActivity model rejects a naive date or a bad WBS code at construction time, so a malformed TASK row raises loudly instead of poisoning a downstream schedule comparison.

Discipline = Literal["ARCH", "STR", "MEP", "CIV", "ELEC", "PLMB"]
WBS_PATTERN = r"^PROJ-\d{3}-(ARCH|STR|MEP|CIV|ELEC|PLMB)-\d{2}$"


class ScheduleActivity(BaseModel):
    project_id: str = Field(min_length=1, max_length=40)
    activity_id: str = Field(min_length=1, max_length=40)   # P6 task_code
    activity_name: str = Field(min_length=1, max_length=240)
    wbs_node: str = Field(pattern=WBS_PATTERN)
    discipline: Discipline
    start_date: datetime
    finish_date: datetime
    total_float_days: int

    @field_validator("start_date", "finish_date")
    @classmethod
    def _tz_aware(cls, v: datetime) -> datetime:
        if v.tzinfo is None:
            raise ValueError("schedule dates must be timezone-aware")
        return v.astimezone(timezone.utc)

    @property
    def is_critical(self) -> bool:
        return self.total_float_days <= 0


def _p6_dt(raw: str) -> datetime:
    # XER date form is 'YYYY-MM-DD HH:MM'; treat P6 clock time as project UTC.
    return datetime.strptime(raw, "%Y-%m-%d %H:%M").replace(tzinfo=timezone.utc)


def build_activity(task: dict[str, str], project_id: str, wbs_node: str,
                   discipline: Discipline, day_hours: float = 8.0) -> ScheduleActivity:
    float_hr = float(task.get("total_float_hr_cnt", "0") or 0.0)
    return ScheduleActivity(
        project_id=project_id,
        activity_id=task["task_code"],
        activity_name=task["task_name"],       # embedded tabs already preserved
        wbs_node=wbs_node,
        discipline=discipline,
        start_date=_p6_dt(task["target_start_date"]),
        finish_date=_p6_dt(task["target_end_date"]),
        total_float_days=int(round(float_hr / day_hours)),
    )


if __name__ == "__main__":
    export = Path("schedule.xer")
    tasks = [row for table, row in read_xer_tables(export) if table == "TASK"]
    logger.info("read %d TASK rows", len(tasks))
    if tasks:
        act = build_activity(tasks[0], project_id="TWR-014",
                             wbs_node="PROJ-014-STR-02", discipline="STR")
        print(act.model_dump_json(indent=2))

Common Mistakes and Gotchas

Opening the file as UTF-8. The default open() codec is UTF-8, and the first Windows-1252 smart quote (0x92) in an activity name raises UnicodeDecodeError. The reflex fix — errors="ignore" — is worse: it deletes the offending byte, so "Owner's steel" becomes "Owners steel" and the WBS name match downstream quietly degrades. Always decode as cp1252 and let genuinely corrupt files fail.

Splitting on tabs by hand. line.split("\t") looks equivalent to a CSV read until an activity name contains a literal tab or a note field spans two lines. Then one logical row becomes two malformed ones, every subsequent column shifts, and float lands under the wrong header — with no exception raised. The csv reader with newline="" is what keeps multi-line and tab-bearing fields intact.

Reading fields by position. Hard-coding cells[7] for total_float_hr_cnt works until the client upgrades P6 and a new column appears earlier in the %F list. Every positional index after it now points one field left, and the import reads plausible-but-wrong data. Zipping each %R row against the %F headers for its table makes the reader immune to column additions — the field is fetched by name or it is absent.

Where This Fits in the Pipeline

This reader is the front door of Primavera P6 export parsing: it turns the raw XER stack of tables into the row dicts every later stage consumes. The TASK rows it yields become typed activities and bind to a canonical WBS node; the TASKPRED rows it surfaces are handed to extracting activity relationships, which rebuilds the dependency graph from predecessor and successor links. Once activities and relationships are typed, the resolved records feed critical path delta detection, where two schedule updates are compared for float and longest-path movement. Because this stage keys every row by header name and preserves every character, the comparison downstream is trustworthy — nothing it sees was silently dropped at the boundary.

Frequently Asked Questions

Why is the XER file encoded in CP1252 rather than UTF-8?

XER is an older interchange format written by a Windows application, so P6 emits it as Windows-1252 (cp1252). Activity names carry smart quotes, en dashes, and degree signs from that code page. Opening the file as UTF-8 raises UnicodeDecodeError on the first such byte, and suppressing the error deletes characters. Decoding explicitly as cp1252 is the only lossless option.

What do the %T, %F, %R, and %E markers mean?

They are XER’s record-type markers on the first tab-delimited cell of each line. %T opens a table and names it, %F declares that table’s column headers, %R is a data row for the current table, and %E marks the end of the file. A reader tracks the current table and its headers as it streams, zipping each %R row against the most recent %F header list.

Why use the csv module instead of splitting on tabs?

P6 activity names and notes contain literal tabs and embedded newlines. A bare str.split("\t") turns one such row into several malformed ones and shifts every downstream column, with no error raised. The csv reader with delimiter="\t" and newline="" treats those characters as field content, so each logical row survives intact and stays aligned with its headers.

How do I stay compatible when P6 adds columns in a new version?

Never read fields by numeric index. Each table declares its own %F header row, so zipping the %R values against those headers gives a dict keyed by field name. When a newer P6 release inserts a column, the header list grows and your row["total_float_hr_cnt"] still resolves correctly, whereas a positional cells[7] would silently read the neighbouring field.

What should happen when a row's width does not match its headers?

Treat it as corruption, not data. If a %R row has a different number of values than its table’s %F headers, the reader logs a warning and skips the row rather than zipping a misaligned dict that would place float under the wrong key. Silent best-effort alignment is exactly how bad schedule data reaches a change-order calculation undetected.

← Back to Primavera P6 Export Parsing