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.%Tbegins a table, names it (%T TASK).%Fgives the column names for the current table, in order.%Ris 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
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:
| Table | Key | What it carries |
|---|---|---|
PROJECT | proj_id | One row per project. A file can hold several. |
PROJWBS | wbs_id, parent_wbs_id | The WBS tree, self-referential. |
TASK | task_id, wbs_id, clndr_id | Activities: durations, dates, float, % type. |
TASKPRED | task_id, pred_task_id | Logic links, with type (FS/SS/FF/SF) and lag. |
CALENDAR | clndr_id | Work patterns and exceptions; day_hr_cnt lives here. |
RSRC, TASKRSRC | rsrc_id, taskrsrc_id | Resources and their assignments. |
ACTVTYPE, ACTVCODE, TASKACTV | actv_code_id | Code 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());
}
}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()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");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]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_Drtnuses duration,CP_Physusesphys_complete_pct,CP_Unitsuses units. Readingphys_complete_pctunconditionally returns garbage on most activities. - Completed activities carry stale forecast dates. P6 leaves the old
early_*dates on a finished task. Readact_*first; comparing a staleearly_endto the baseline invents slip that never happened. - Float is null on completed work.
total_float_hr_cntis empty once an activity is done. Treat null as not-critical, never as zero. - Durations are hours, not days. Every
*_drtn_hr_cntis 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_LOEfrom 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 are06When 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.