What an XER file is
XER is Primavera P6's own export format: a flat, tab-delimited text file holding a dump of database tables. It is not XML, it is not compressed, and it carries no schema. A schedule arrives as rows of a relational export, and whether a reader gets the right dates out of it depends almost entirely on which columns it reads and how it decodes the ones that are packed.
This page describes the file structure as this project's importer reads it. It is
deliberately not a field dictionary — Oracle publishes one, and it is the
authority. See the
Oracle Primavera P6 EPPM XER Import/Export Data Map Guide (Project), version 24,
December 2023
for the table and column list. What that guide does not describe is the packed
calendar string, and that is the subject of the sibling page,
Inside clndr_data.
The header line
The first line is not a table. It begins with the literal token ERMHDR,
followed by tab-separated fields. An export written by P6 23.12 looks like this:
ERMHDR 23.12 2026-08-11 12:54 Project ADMIN danaf dbxDatabaseNoName Project Management CAD
The second field is the version of P6 that wrote the file, and it is the only field this project's importer reads from the header — it is carried through as the source version, because "which tool wrote this" is a fact a reader of a report is entitled to. The remaining fields, as seen in real exports, carry the export timestamp, the export type, the user login and display name, the database name, the module name and the currency. Their authoritative meaning is Oracle's to state, not ours.
Two things the header does not carry are worth naming, because both cost a reader something:
- No character encoding. More on that below.
- No record count and no checksum. A file truncated in transit is structurally indistinguishable from a short one, except that it will usually lack its terminator.
The record grammar
After the header, the file is a sequence of four line markers. Each marker is the first tab-separated field on its line.
| Marker | Meaning |
|---|---|
%T |
begins a table; the next field is the table name |
%F |
the column names for the table just opened |
%R |
one row; its fields align positionally with the preceding %F |
%E |
end of file |
A minimal well-formed fragment:
%T PROJECT
%F proj_id proj_short_name plan_start_date
%R 1 NGT-2026 2026-09-07 00:00
%E
Tables appear in whatever order the exporter wrote them, a table may be absent
entirely, and a %F line applies only until the next %T. There is no nesting:
relationships between tables are by key, exactly as in the database the file was
dumped from.
The row, and what makes it harder than it looks
A row is split on tab characters. XER has no quoting and no escape sequence, which produces three consequences a tokenizer has to handle explicitly.
A tab inside a value forges a column. There is no way to distinguish it from
a field separator. A row that comes out with more fields than its header has
almost certainly suffered this, and the honest response is to say so rather than
silently keep the surplus. This importer reports it as XER.ROW.LONG; a row that
is short of fields is padded and reported as XER.ROW.SHORT, because losing a
whole activity over a trailing empty field is the worse failure.
A newline inside a value splits the row across physical lines. P6 writes
notebook topics (TASKMEMO.task_memo) containing literal newlines, so one
logical row can span many lines of the file. A line that starts with none of the
four markers is therefore a continuation of the row above it, not junk. Reading
it as junk truncates the note and discards every field after it.
Splitting on "any line break" is wrong. Real activity names contain vertical tab, form feed, the ASCII file, group and record separators, NEL and the Unicode line and paragraph separators. Every one of those is treated as a line break by the naive splitting routine most languages offer, and treating one as a row terminator invents a row boundary that is not in the file. This importer splits on the newline character alone.
A doubled quote is one quote. Cells are never wrapped in quotes, but P6
doubles an embedded quote character when it writes a text cell — a planner's
1" pipe is stored as 1"" pipe. A cell that is exactly two quote characters
holds one quote, not an empty string. Note the distinction from dequoting: a name
that genuinely begins and ends with a quote survives undoubling and must not have
its outer quotes stripped.
How the tables relate
Six tables carry the schedule itself. Everything else is metadata, codes, costs, notes or definitions.
| Table | Holds | Joins on |
|---|---|---|
PROJECT |
one row per project: data date, planned start, the Must Finish By deadline, the default calendar | proj_id |
PROJWBS |
the work breakdown structure, as a parent-pointer tree | wbs_id, parent_wbs_id, proj_id |
TASK |
activities: code, name, type, status, durations in hours, stored dates, constraint | task_id, proj_id, wbs_id, clndr_id |
TASKPRED |
the logic: one row per relationship | task_id (successor), pred_task_id, pred_type, lag_hr_cnt |
CALENDAR |
working time | clndr_id, base_clndr_id |
SCHEDOPTIONS |
the scheduling settings that change the answer | proj_id |
Four traps in that mapping are worth stating because they change computed dates rather than raising an error:
- Relationship types are prefixed. P6 writes
PR_FS,PR_SS,PR_FF,PR_SF. Slicing off the prefix rather than mapping the four values by hand turns an unexpected value into a silent finish-to-start, which moves the critical path with nothing logged. - Durations are hours, against a calendar.
TASK.target_drtn_hr_cntis hours, and the divisor isCALENDAR.day_hr_cnt— not always eight. Ignoring it rescales every duration in the file. - Milestones have zero duration. Clamping to a minimum of one turns them into one-day activities and changes the network.
- Costs and notes are elsewhere.
TASKhas no cost column; costs are inTASKRSRCand notes inTASKMEMO. Reading them fromTASKyields zero.
And one omission is worse than any of them: an importer that skips TASKPRED
produces a network with no logic at all, in which every activity reports as
critical with zero float and nothing about the output looks broken.
Nothing declares the character encoding
ERMHDR states a version, a date, a user and a currency, and says nothing about
how the bytes are encoded. P6 writes the database code page, most often
Windows-1252. Anything that has been through a modern editor comes back as UTF-8,
sometimes with a byte-order mark, and files routed through some mail systems
arrive as UTF-16.
Decoding as UTF-8 raises an exception on the Windows-1252 files, which is the
likeliest way a first real file fails: a traceback naming a byte offset, from a
file that is not corrupt at all. Decoding with replacement characters is worse in
a different direction, because an activity named Béton becomes B?ton with
nothing logged, and the identifier a finding is matched on may be the thing that
was mangled.
The failure that has no error attached at all is a code page that is not Windows-1252. An export from an Arabic-locale install carries cp1256, and Windows-1252 maps almost every byte of it to some Latin letter — producing plausible-looking noise standing exactly where an activity name should be, with nothing raised. This importer tests the text for whole words written in one script before falling back, and reports which code page it chose, so the choice is visible in the report rather than assumed. The method and its measured false-negative cases are in this repository's encoding notes.
One file, many projects
A single XER can hold several PROJECT rows, and in practice they are often a
real schedule paired with one or more near-empty stubs — a one-activity shell, or
a project record with no activities at all. Which one is written first is not
governed by size.
A reader that silently takes the first project can therefore produce a complete,
confident report about a one-activity stub while the several-thousand-activity
schedule beside it is never read. This importer enumerates the projects it found and refuses to
choose one on your behalf; the project must be named. The same applies to
TASK.task_id uniqueness across a multi-project file, which a batch caller has to
be able to rely on.
What a reader owes you
Every default taken, value coerced and row dropped in this importer carries a stable code and a stated action, and those reach the report ahead of any finding. A silent default produces a plausible schedule that is wrong, with nothing in the output to look at — which is the whole argument for the disclosure, and the reason the calendar page next door exists at all.
Next: Inside clndr_data — the packed calendar string, which
Oracle's data map does not describe. See also How it works and
What this does not do.
Source: web/pages/xer-format.md. Source commit date: 2026-09-11.