Metadata de-identification
deidentify(ds) applies the PS3.15 2026d Annex E Basic Application Level Confidentiality Profile
(§E.2) to a parsed object: it replaces, empties or removes every attribute Table E.1-1 lists as
identifying, and returns a fresh Dataset plus a DeidentifyReport of what it did. It is a pure
function; your input dataset is never mutated.
A de-identified output from @cosyte/dicom is metadata-de-identified only. Pixel data is out of
scope: where a file carries burned-in annotation this layer cannot remove, you get a
DICOM_BURNED_IN_ANNOTATION_NOT_REMOVED warning rather than a false sense of safety, and pixel
cleaning is deferred to @cosyte/dicom-pixel. A report that reads clean does not close a residual.
Known limitations is the measured, still-open list, and it is required reading
before you route any of this at real data.
Every DICOM object on this page is synthetic (an invented patient, obviously-fake UIDs) and encoded as a base64 buffer, so an example needs no file on disk. Never paste a real object into a doc or a test.
The shape of a call
import { parseDicom, deidentify, serializeDicom, DEFAULT_UID_ROOT } from "@cosyte/dicom";
const buf = Buffer.from(
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABESUNNAgAAAFVMBAAcAAAAAgAQAFVJFAAxLjIuODQwLjEwMDA4LjEuMi4xAAgAFgBVSRoAMS4yLjg0MC4xMDAwOC41LjEuNC4xLjEuMgAIABgAVUkeADEuMi44MjYuMC4xLjM2ODAwNDMuOC40OTguMTExAAgAIABEQQgAMTkwMDAxMDEIADAAVE0GADA5MDAwMAgAUABTSAgAQUNDLTAwMDEIAGAAQ1MCAENUEAAQAFBOCABEb2VeSmFuZRAAIABMTwYATVJOLTQyEAAhAExPDABTQU1QTEUtSE9TUCAgAA0AVUkeADEuMi44MjYuMC4xLjM2ODAwNDMuOC40OTguMS4xACAADgBVSR4AMS4yLjgyNi4wLjEuMzY4MDA0My44LjQ5OC4xLjIAIAARAElTAgAyICgAAgBVUwIAAQAoAAQAQ1MMAE1PTk9DSFJPTUUyICgAEABVUwIAAAIoABEAVVMCAAACKAAwAERTCAAwLjVcMC41ICgAAAFVUwIAEAAoAAEBVVMCAAwAKAACAVVTAgALACgAAwFVUwIAAQAoAFIQRFMGAC0xMDI0ICgAUxBEUwIAMSA=",
"base64",
);
const ds = parseDicom(buf);
const { dataset, report } = deidentify(ds);
// Pure: the input is untouched, the copy is scrubbed.
ds.patient.id; // => "MRN-42"
dataset.patient.id; // => undefined
// Patient's Name is emptied (Annex E action "Z"), not left in place.
dataset.get("00100010")?.value.kind; // => "empty"
// UIDs are remapped under the default root, deterministically.
DEFAULT_UID_ROOT; // => "2.25"
dataset.study.instanceUid?.startsWith(`${DEFAULT_UID_ROOT}.`); // => true
// The audit trail says what was acted on, and the object says of itself
// that dates were not retained on this call.
report.attributes.length > 0; // => true
dataset.get("00280303")?.value; // => { kind: "strings", values: ["REMOVED"] }
// The de-identified copy serializes to bytes you can share.
Buffer.isBuffer(serializeDicom(dataset)); // => true
The options surface
| Export | What it is |
|---|---|
DeidentifyOptions | The options object: retain, uidRoot, uidMap, profile, deidentificationMethod. Every field is optional; the default is the Basic Profile with no Option active. |
DeidentifyOption | One Annex E option-set name. The two pixel-facing Options (CleanPixelData, CleanRecognizableVisual) are excluded by type, because this layer does not touch pixels. |
DEIDENTIFY_OPTIONS | The frozen registry of the metadata option-set names, validated at runtime. It is the list: read it rather than a count written anywhere. |
DeidentifyResult<T> | What the call returns: { dataset, report }, generic in the dataset type so the parsed type flows through. |
AppliedAction | What actually happened to one attribute: removed (X), emptied (Z), dummied (D), uid-remapped (U), cleaned (C), or kept. The resolved outcome, not the table's code. |
An Option is opt-in and each one keeps a class of attribute the Basic Profile would otherwise strip:
import { parseDicom, deidentify, DEIDENTIFY_OPTIONS } from "@cosyte/dicom";
const buf = Buffer.from(
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABESUNNAgAAAFVMBAAcAAAAAgAQAFVJFAAxLjIuODQwLjEwMDA4LjEuMi4xAAgAFgBVSRoAMS4yLjg0MC4xMDAwOC41LjEuNC4xLjEuMgAIABgAVUkeADEuMi44MjYuMC4xLjM2ODAwNDMuOC40OTguMTExAAgAIABEQQgAMTkwMDAxMDEIADAAVE0GADA5MDAwMAgAUABTSAgAQUNDLTAwMDEIAGAAQ1MCAENUEAAQAFBOCABEb2VeSmFuZRAAIABMTwYATVJOLTQyEAAhAExPDABTQU1QTEUtSE9TUCAgAA0AVUkeADEuMi44MjYuMC4xLjM2ODAwNDMuOC40OTguMS4xACAADgBVSR4AMS4yLjgyNi4wLjEuMzY4MDA0My44LjQ5OC4xLjIAIAARAElTAgAyICgAAgBVUwIAAQAoAAQAQ1MMAE1PTk9DSFJPTUUyICgAEABVUwIAAAIoABEAVVMCAAACKAAwAERTCAAwLjVcMC41ICgAAAFVUwIAEAAoAAEBVVMCAAwAKAACAVVTAgALACgAAwFVUwIAAQAoAFIQRFMGAC0xMDI0ICgAUxBEUwIAMSA=",
"base64",
);
const ds = parseDicom(buf);
// The registry is the source of truth for what may be retained.
DEIDENTIFY_OPTIONS.includes("RetainLongitudinalTemporal"); // => true
DEIDENTIFY_OPTIONS.includes("CleanPixelData"); // => false
// Default: dates go, and the object declares that.
deidentify(ds).dataset.get("00280303")?.value.kind; // => "strings"
deidentify(ds).dataset.study.date?.raw; // => undefined
// With the full-dates Option: the real dates are in the output, and the
// declaration changes with them.
const dated = deidentify(ds, { retain: ["RetainLongitudinalTemporal"] }).dataset;
dated.get("00280303")?.value; // => { kind: "strings", values: ["UNMODIFIED"] }
dated.study.date?.raw; // => "19000101"
// With the modified-dates Option: the other E.3.6 column, and the third state.
const shifted = deidentify(ds, {
retain: ["RetainLongitudinalTemporalModifiedDates"],
});
shifted.dataset.get("00280303")?.value; // => { kind: "strings", values: ["MODIFIED"] }
shifted.report.warnings.map((w) => w.code); // => ["DICOM_DEIDENT_DATES_NOT_TRANSFORMED"]
RetainLongitudinalTemporal carries the less protective of the two PS3.15 §E.3.6 columns, and
RetainLongitudinalTemporalModifiedDates carries the other. The standard defines a full-dates and
a modified-dates Option and Table E.1-1 gives them separate columns; the first name is the full-dates
branch, so on the attributes where the columns disagree you keep the real value where modified-dates
would have cleaned it. They are mutually exclusive and a call naming both is rejected with a
DeidentifyError. Date shifting is not performed at this layer, on either branch: §E.3.6 also
requires the dates themselves to be modified and the manner described in a Conformance Statement, and
both are yours, so every modified-dates run raises DICOM_DEIDENT_DATES_NOT_TRANSFORMED on its
report. Activate the full-dates Option only when real dates are genuinely required, and see
Known limitations for the whole of that residual.
What the object records about the method
PS3.15 2026c §E.1.1 asks a de-identifier to record how it ran, in codes from CID 7050
"De-identification Method" "corresponding to the Profile and Options used", which "shall be added to
De-identification Method Code Sequence (0012,0064), and/or a text string describing the method used
shall be inserted in or added to De-identification Method (0012,0063)". deidentify() writes both,
on every run. The text in (0012,0063) was already there; (0012,0064) is the machine-readable
half, so you can switch on a code instead of parsing English, and tell the two §E.3.6 branches
apart by 113106 and 113107 rather than only by the (0028,0303) value.
(0012,0064) is a top-level SQ with an Item for each code from PS3.16 2026d CID 7050 (context
group version 20170914, UID 1.2.840.10008.6.1.925): 113100 for the Profile first, then an Item
for each active Option in DEIDENTIFY_OPTIONS order, whatever order retain names them in. Each
Item carries exactly Code Value (0008,0100), Coding Scheme Designator (0008,0102) = DCM and
Code Meaning (0008,0104), and nothing else.
import { Dataset, deidentify, DEIDENTIFICATION_METHOD_CODES } from "@cosyte/dicom";
// An empty Data Set keeps this short: the codes follow the options, not the content.
const empty = new Dataset({ warnings: [], elements: new Map() });
const { dataset } = deidentify(empty, { retain: ["RetainUIDs", "CleanDescriptors"] });
// The Profile, then each active Option in DEIDENTIFY_OPTIONS order.
const codeValues = (dataset.get("00120064")?.items ?? []).map((item) => {
const value = item.get("00080100")?.value;
return value?.kind === "strings" ? value.values[0] : undefined;
});
codeValues; // => ["113100", "113105", "113110"]
// The same rows, as data you can compare against.
DEIDENTIFICATION_METHOD_CODES.options.RetainUIDs.codeValue; // => "113110"
DEIDENTIFICATION_METHOD_CODES.profile.codeMeaning; // => "Basic Application Confidentiality Profile"
// Running it again adds nothing, and says it kept what was already there.
const again = deidentify(dataset, { retain: ["RetainUIDs", "CleanDescriptors"] });
again.dataset.get("00120064")?.items?.length; // => 3
again.report.warnings.map((w) => w.code); // => ["DICOM_DEIDENT_METHOD_PRIOR_RETAINED", "DICOM_DEIDENT_METHOD_CODES_PRIOR_RETAINED"]
| Code Value | Code Meaning | Written when |
|---|---|---|
113100 | Basic Application Confidentiality Profile | every run |
113101 | Clean Pixel Data Option | never |
113102 | Clean Recognizable Visual Features Option | never |
113103 | Clean Graphics Option | CleanGraphics |
113104 | Clean Structured Content Option | CleanStructuredContent |
113105 | Clean Descriptors Option | CleanDescriptors |
113106 | Retain Longitudinal Temporal Information Full Dates Option | RetainLongitudinalTemporal |
113107 | Retain Longitudinal Temporal Information Modified Dates Option | RetainLongitudinalTemporalModifiedDates |
113108 | Retain Patient Characteristics Option | RetainPatientCharacteristics |
113109 | Retain Device Identity Option | RetainDeviceIdentity |
113110 | Retain UIDs Option | RetainUIDs |
113111 | Retain Safe Private Option | RetainSafePrivate |
113112 | Retain Institution Identity Option | RetainInstitutionIdentity |
Added to, never replaced. When the source already carries a top-level (0012,0064), every
Item in it is kept, in its original order and byte for byte, and this run's Items follow. A code a
prior Item already records under the same Coding Scheme Designator and Code Value is not added
again (Code Meaning is not part of the match, and trailing padding is ignored), so de-identifying
an output again with the same options is a fixed point, and a run that adds one further Option
appends that Option's Item and nothing else.
The limits of what these codes say, stated here rather than on a later page:
113101and113102are never written. They name the two pixel-level Options, and this layer does not touch pixels.113107says the modified-dates column was resolved, not that any date was shifted. It is the same limit(0028,0303) = MODIFIEDcarries: this library transforms no date, and every run that writes113107still carriesDICOM_DEIDENT_DATES_NOT_TRANSFORMEDonreport.warnings.- An Option's code means the Option was active for the run. It is written whether or not the
object carried an attribute that Option acts on, and your
deidentificationMethodtext never changes the codes: they follow the options that ran. - Prior Items are carried, not inspected. Neither
(0012,0064)nor the code attributes in its Items has a Table E.1-1 row, so what a sender wrote there reaches your output under(0012,0062) = YES, and no prior code is checked against CID 7050 or any terminology. Every run that keeps a prior Item says so withDICOM_DEIDENT_METHOD_CODES_PRIOR_RETAINED. - A prior that is not a Sequence of Items is replaced. A
(0012,0064)encoded under a VR other thanSQ, or anSQwhose items were not parsed, cannot be added to, so this run's Items stand alone andDICOM_DEIDENT_METHOD_CODES_PRIOR_REPLACEDsays so (a padding-only prior raises nothing, because nothing was lost). - Top level only. A copy nested inside a Sequence Item is neither written nor read, the same
reach
(0012,0062)and(0012,0063)have.
| Export | What it is |
|---|---|
DEIDENTIFICATION_METHOD_CODES | The CID 7050 rows as frozen data, with the context group UID and version, the Profile's code and the code each DeidentifyOption writes. |
DeidentificationMethodCode | One row: codeValue, codingSchemeDesignator (DCM) and codeMeaning, spelled exactly as CID 7050 spells them. |
UID remapping
Action U replaces a UID with an internally-consistent one. The replacement is a pure function of
the source UID and the root, so the same source UID maps to the same replacement across calls and
across files in a study set, with no shared state: cross-instance referential integrity (Study to
Series to SOP, Frame of Reference, referenced instances) survives the scrub.
| Export | What it is |
|---|---|
DEFAULT_UID_ROOT | The DICOM-sanctioned UUID-derived arc (PS3.5 §B.2), which needs no registration. The default uidRoot. |
makeUidRemapper | Builds a remapper over a root and an optional caller-owned cache, for remapping UIDs outside a deidentify call. Throws DeidentifyError on a bad root. |
UidRemapper | What it returns: map(sourceUid) plus the cache it fills, exposed for reporting and reuse. |
import { makeUidRemapper, DEFAULT_UID_ROOT } from "@cosyte/dicom";
const remap = makeUidRemapper(DEFAULT_UID_ROOT);
const replaced = remap.map("1.2.826.0.1.3680043.8.498.1.1");
// Content-derived, so it is stable without any shared state...
replaced.startsWith("2.25."); // => true
remap.map("1.2.826.0.1.3680043.8.498.1.1") === replaced; // => true
// ...and a fresh remapper on the same root agrees with it.
makeUidRemapper().map("1.2.826.0.1.3680043.8.498.1.1") === replaced; // => true
// The cache is the source-to-replacement map, exposed for your own reporting.
remap.cache.get("1.2.826.0.1.3680043.8.498.1.1") === replaced; // => true
Pass your own uidMap through DeidentifyOptions if you would rather share one map explicitly
across a whole archive; the mapping is consistent either way, and a shared map only makes repeats
cheaper.
Attributes neither table carries
Table E.1-1 lists the attributes the Basic Profile judged, and an attribute it does not list is kept. That is right for an attribute the PS3.6 2026d data dictionary registers, which the Profile has considered, and it is how every unlisted attribute was handled before this rule. It is not right for an attribute nobody considered: the notes to PS3.15 2026d Table E.1-1 name "new Standard Attributes" among the places identifying information may be, and say that removing only the known risks "may fail when the Standard is extended, or when a vendor adds unanticipated Standard Attributes".
So deidentify() removes a non-private attribute that neither this build's PS3.6 2026d registry
nor Table E.1-1 carries, at the top level and inside every Sequence Item it walks, whatever its VR
(UN, a VR outside the set PS3.5 §6.2 defines, and SQ included; a Sequence goes whole and nothing
inside it is walked). "Registered" means a literal PS3.6 row, or a masked row the tag matches: the
(50xx,....) and (60xx,....) families only across the even groups PS3.5 §7.6 bounds them to, so
(6002,0010) Overlay Rows is kept and (6020,0010) is removed, and every other masked row, such as
(0028,04x0), as PS3.6 prints it. Private attributes, (0004,xxxx), (0002,xxxx), group lengths
(gggg,0000) and every tag Table E.1-1 lists keep the handling the rest of this page describes.
Each removal is recorded on report.unregisteredElementRemovals by its byte offset, and by the
Sequences the run descended to reach it, never by tag or VR: a tag no registry row carries is
exactly what four bytes read out of the middle of some element's value look like, so the tag may
itself be document content. The list is capped per run and
report.unregisteredElementRemovalCount carries the complete total, and
DICOM_DEIDENT_UNREGISTERED_ELEMENT_REMOVED is raised once per run that removed anything, with no
tag, VR or count in its message.
The limit, stated beside the capability: a conformant attribute from a PS3.6 edition newer than this build is removed as well, because nothing on the wire separates it from one a sender invented. That is over-redaction, it is deliberate, and there is no option in this release to keep such an attribute. It also narrows the older gap rather than closing it: an attribute PS3.6 registers and the pinned Table E.1-1 does not list is still kept exactly as the source wrote it, which is the Profile's own design.
The report, and what it is not safe to log
DeidentifyReport is an audit trail, not a redacted surface. Several of its fields carry source
bytes rather than table lookups, and they are named on the type. Read that list before logging a
report whole, and read Keeping PHI out of logs for the
mechanism. An emptied audit is not a performed one: a report that reads as a complete scrub it did
not perform is the worse half of every residual on the limitations page.
| Export | What it records |
|---|---|
DeidentifiedAttribute | One audited attribute: its tag, keyword, resolved Annex E action and the AppliedAction that action produced. |
EmbeddedAttributeFinding | A carrier whose over-declared Value Length swallowed the element after it, emptied rather than kept. hidden lists only tags this run acted on that Table E.1-1 gives a literal row, so it can be empty on a real finding. |
UnauditableSequenceFinding | An SQ that reached the run with no items, so its item stream could not be walked and it was emptied rather than passed through. |
UndefinedVrFinding | An element whose on-wire VR is outside the set PS3.5 §6.2 defines, emptied because no Table E.1-1 row can say what its bytes mean. It names a byte offset and deliberately no tag. |
UnenumerablePrivateRemoval | A private attribute a Profile vouched for under RetainSafePrivate whose value this run did not enumerate, so §E.3.10's "known to be safe" was never established and it was removed, not emptied. |
FileMetaDroppedElement | A non-modeled (0002,xxxx) element the source carried that is not in the output, with the VR and byte length that went with it. A deliberate fidelity loss, recorded because what was dropped is the audit value. |
Group0004Removal | A (0004,xxxx) element removed under §E.1.1's unconditional group-0004 rule. No Option brings one back; the DICOMDIR carve-out is the one object it does not apply to. |
UnregisteredElementRemoval | A non-private element removed because neither this build's PS3.6 registry nor Table E.1-1 carries its tag. It names a byte offset and deliberately no tag or VR, and a newer edition's conformant attribute lands here too. |
Errors
| Export | What it is |
|---|---|
DEIDENTIFY_ERROR_CODES | The frozen registry of codes this layer throws: INVALID_OPTIONS for an author-time misconfiguration of the call, and UNSUPPORTED_TRANSFER_SYNTAX for a Dataset under one of the four JPIP Referenced syntaxes, which is refused whatever the options (see Scope limits below). |
DeidentifyErrorCode | The discriminant type over that registry. |
DeidentifyError | The thrown class, distinct from DicomParseError, DicomValueError and DicomSerializeError. Its message carries only option names and the UID root, or a fixed string for UNSUPPORTED_TRANSFER_SYNTAX, never a decoded value. |
import { makeUidRemapper } from "@cosyte/dicom";
// A UID root that is not a dotted-decimal OID prefix is an author error, not a file error.
makeUidRemapper("not-an-oid");
// throws DeidentifyError (INVALID_OPTIONS)
Scope limits on this page's subject
These are boundaries, not defects; the full list is on Known limitations.
- Metadata only. Burned-in annotation is warned, never removed. Pixel scrubbing is
@cosyte/dicom-pixel. - A JPIP Referenced object is refused, not de-identified. When
ds.fileMeta.transferSyntaxUIDis one of the four JPIP Referenced syntaxes (PS3.5 2026c sections A.6, A.7, A.11 and A.12),deidentify()throws aDeidentifyErrorwith codeUNSUPPORTED_TRANSFER_SYNTAXbefore it reads your options or the Data Set, and returns no dataset or report. Such an object's Pixel Data Provider URL(0028,7FE0)has no Table E.1-1 row, so a de-identified copy would keep it by omission. The refusal keys on the File Meta Transfer Syntax alone: a non-JPIP object that carries(0028,7FE0)anyway is de-identified like any other object, and that URL is kept, as every registered attribute Table E.1-1 does not list is kept. - A standard attribute newer than this build is removed, conformant or not. An attribute with no row in this build's PS3.6 registry and none in Table E.1-1 is removed and recorded without its tag (see Attributes neither table carries), so an object from a later edition loses its new attributes. There is no switch to keep them in this release.
- Conditional Annex E codes collapse to their most protective branch. There is no IOD Type-1 analysis here, so where the table's action depends on the object's IOD this run takes the branch that removes rather than the one that keeps.
RetainSafePrivatekeeps only what the run could account for. Two routes write a private value into de-identified output: aProfileyou pass, and the file's own Private Data Element Characteristics Sequence(0008,0300), which needs no profile at all. Everything else private is removed and recorded. The cost is over-redaction on the profile route, and it is nearly all of that route; on the declaration route the cost runs the other way, because what is kept rests on the sender's assertion that the block carries no identifying information rather than on anything this run examined. If you do not trust the sender, leave the Option off: PS3.15 §E.3.10 offers no other mitigation.- The byte-for-byte File Meta round trip does not hold for this output. The group is replaced with a description of the de-identifying application (§E.1.1), so the Source AE Title and the source's implementation identity go with it. That loss is recorded, and it is not recoverable from the output. If you need the source group verbatim, read it off the parsed dataset before the call, and see Serialization for the round trip that does hold.
- A de-identified DICOMDIR is not File-set conformant. Its Directory Records are de-identified as
Data Sets and
serializeDicomrewrites their offsets to where they land, but the run creates no DICOMDIR from the de-identified files and removes no non-de-identified DICOMDIR from the File-set, andDICOM_DEIDENT_DICOMDIR_FILE_SET_NOT_DISCHARGEDnames exactly those two clauses. Referenced File IDs(0004,1500)are kept verbatim. Build a de-identified File-set from the de-identified files, not from this output; see Known limitations.
Where to go next
- A worked recipe, including what the File Meta group stops naming: Cookbook.
- What a diagnostic may and may not carry: Tolerance & warnings.
- Vouching for known-safe private attributes: Source profiles.
- The measured, still-open residuals: Known limitations.