Badr Abardazzou

Papers

Reading an XER with MPXJ

The format's grammar, its relational model, and the parse that actually reconstructs the schedule.

This is the companion to Your XER is not a schedule. That piece argued the XER is locked; this one shows how to open it: the grammar of the file, the tables you must join, and how to do it with MPXJ without falling into the half-dozen traps that produce a plausible but wrong schedule.

01The grammar

An XER is Windows-1252 encoded text, tab-separated, line-oriented. Four record types carry everything:

  • ERMHDR, the first line: format version, export date, currency, the P6 build that wrote it.
  • %T begins a table, names it (%T TASK).
  • %F gives the column names for the current table, in order.
  • %R is one data row, fields positional against the last %F.

Tables appear in dependency order and are joined by integer keys. The parse is trivial; the meaning is not.

ERMHDR	23.8	2026-06-30	Project	admin	...	USD

%T	CALENDAR
%F	clndr_id	clndr_name	day_hr_cnt	clndr_data
%R	8	5x8 Standard	8	(0||CalendarData()( ... ))

%T	TASK
%F	task_id	clndr_id	wbs_id	task_code	task_name	status_code	target_drtn_hr_cnt	remain_drtn_hr_cnt	total_float_hr_cnt	complete_pct_type	phys_complete_pct	act_start_date	early_end_date	target_end_date
%R	316296	8	2051	E-CIV-1100	Pile caps, Row A	TK_Complete	240	0	0	CP_Drtn	0	2026-04-02	2026-05-11	2026-05-08
One calendar and one activity. The task points at clndr_id 8; its duration of 240 hours is only 30 days because that calendar says a day is 8 hours. Nothing about that conversion is in the TASK row.

02The relational model

A schedule is not a table, it is a graph. These are the tables you have to reconstruct and join before a single date means anything:

TableKeyWhat it carries
PROJECTproj_idOne row per project. A file can hold several.
PROJWBSwbs_id, parent_wbs_idThe WBS tree, self-referential.
TASKtask_id, wbs_id, clndr_idActivities: durations, dates, float, % type.
TASKPREDtask_id, pred_task_idLogic links, with type (FS/SS/FF/SF) and lag.
CALENDARclndr_idWork patterns and exceptions; day_hr_cnt lives here.
RSRC, TASKRSRCrsrc_id, taskrsrc_idResources and their assignments.
ACTVTYPE, ACTVCODE, TASKACTVactv_code_idCode types, values, and per-task assignment.

An activity's finish date is a function of its start, its duration in hours, and its calendar's working pattern and exceptions. Its position in the plan is a function of its predecessors and their lags. Its criticality is a function of re-running the forward and backward pass over the whole network. Load only TASK and you have durations floating in space. This is the work a reader has to do, and it is why a spreadsheet cannot.

03MPXJ does the rebuild

MPXJ is a mature Java library that reads XER, P6 XML, MPP, MSPDI and a dozen other formats into one ProjectFile model, with the calendars and relationships already resolved. It is the shortest correct path from a file to a schedule. In Java:

import net.sf.mpxj.*;
import net.sf.mpxj.reader.UniversalProjectReader;

ProjectFile project = new UniversalProjectReader().read("project.xer");

for (Task task : project.getTasks()) {
    // getDuration() is calendar-aware: hours resolved to working days
    System.out.printf("%s  %s -> %s  (%s)%n",
        task.getName(), task.getStart(), task.getFinish(), task.getDuration());

    for (Relation pred : task.getPredecessors()) {
        System.out.printf("   after %s  %s + %s%n",
            pred.getTargetTask().getName(), pred.getType(), pred.getLag());
    }
}
A calendar-aware read in a dozen lines. getStart, getFinish and getDuration already account for the calendar; the relationships come back as objects, not id numbers.

The same from Python, which drives the identical Java library through JPype:

# pip install mpxj
import jpype, mpxj
jpype.startJVM()
from net.sf.mpxj.reader import UniversalProjectReader

project = UniversalProjectReader().read("project.xer")
for task in project.getTasks():
    print(task.getName(), task.getStart(), task.getFinish())
jpype.shutdownJVM()
Python, same model. The mpxj package bundles the jars; you are calling the Java classes directly.

04Manipulate and convert

Because every format lands in the same model, converting is two lines. Read an XER, write MS Project XML, JSON, or a Gantt-ready structure of your own:

import net.sf.mpxj.writer.UniversalProjectWriter;
import net.sf.mpxj.writer.FileFormat;

ProjectFile project = new UniversalProjectReader().read("project.xer");

// hand it to anything that reads MS Project
new UniversalProjectWriter(FileFormat.MSPDI).write(project, "project.xml");

// or JSON for the web
new UniversalProjectWriter(FileFormat.JSON).write(project, "project.json");
XER in, open formats out. The same call writes MSPDI, JSON, SDEF, Planner and more. This is how you get a P6 schedule to a team that has no P6.

To edit rather than convert, mutate the model and write it back: shift a task, add a relation, re-point a calendar, then emit. MPXJ handles multi-project XERs explicitly, which the naive reader gets wrong:

import net.sf.mpxj.primavera.PrimaveraXERFileReader;

// an XER can hold several projects; readAll returns one ProjectFile each
PrimaveraXERFileReader reader = new PrimaveraXERFileReader();
List<ProjectFile> all = reader.readAll(new FileInputStream("multi.xer"));
// pick by name/id; do not silently render project[0]
Multi-project files. read() returns the primary project; readAll() returns every one. Reporting the first and ignoring the rest is a common, silent bug.

05The traps

Whether you use MPXJ or roll your own, the same handful of details separate a correct read from a plausible wrong one. Each of these is a real defect I have watched a parser make:

  • Percent complete is typed. Read the field named by complete_pct_type: CP_Drtn uses duration, CP_Phys uses phys_complete_pct, CP_Units uses units. Reading phys_complete_pct unconditionally returns garbage on most activities.
  • Completed activities carry stale forecast dates. P6 leaves the old early_* dates on a finished task. Read act_* first; comparing a stale early_end to the baseline invents slip that never happened.
  • Float is null on completed work. total_float_hr_cnt is empty once an activity is done. Treat null as not-critical, never as zero.
  • Durations are hours, not days. Every *_drtn_hr_cnt is hours; the day depends on the activity's calendar (day_hr_cnt). MPXJ resolves this; a raw parser must join the calendar itself.
  • LOE and hammock tasks lie about their span. A level-of-effort task stretches to its children and can end years past the real work. Exclude TT_LOE from any timescale auto-fit.
  • Encoding is Windows-1252. Decode as UTF-8 and accented resource and activity names break.

The parse takes an afternoon. The six traps take the project.

Where the bodies are

06When to roll your own

MPXJ is the right default: calendar-aware, multi-format, battle-tested, LGPL. Reach past it only when you need something it does not give cheaply, a pure-browser reader with no Java, or a render tuned to one house standard, and then you are back to parsing %T/%F/%R yourself and re-implementing the calendar and CPM logic by hand. That is the road the XER Gantt tool took, precisely to run with no server and no runtime, and the traps above are its scars.

References. MPXJ by Jon Iles, mpxj.org (LGPL 2.1), reads and writes the XER, P6 XML and MSPDI formats used here. Field names follow Oracle's Primavera P6 XER schema. For the non-technical case, see Your XER is not a schedule.