DEV Community

Cover image for go-dicom — a pure Go DICOM library and CLI, no CGO
Amr shadid
Amr shadid

Posted on • Originally published at lnkd.in

go-dicom — a pure Go DICOM library and CLI, no CGO

If you work in Go and you need to handle medical images, your options until now were unpleasant. Bind to dcmtk or DCMTK-derived C libraries and accept CGO — losing static builds, easy cross-compilation, and a good chunk of your deployment simplicity. Shell out to a Python process running pydicom. Or write the parts of the DICOM standard you need yourself, which is how a lot of hospital integrations quietly end up with a half-finished parser in an internal repo.

I built go-dicom because I hit that wall on a medical imaging platform I maintain. It is a complete DICOM implementation in pure Go — files, pixels, and the network protocol — with no C dependencies at all.

go get github.com/amrshadid/go-dicom
Enter fullscreen mode Exit fullscreen mode

Or, for the CLI:

curl -fsSL https://raw.githubusercontent.com/amrshadid/go-dicom/main/install.sh | sh
Enter fullscreen mode Exit fullscreen mode

The installer works out your platform, downloads the matching build, and refuses to install unless the SHA256 checksum matches the release's SHA256SUMS. It prefers a directory already on your PATH, so nothing needs sudo, and it clears the macOS quarantine flag that otherwise stops Gatekeeper from running a downloaded binary.

What it does

Files. Read and write DICOM in every form you meet in the wild: standard .dcm, Siemens .ima, DICOMDIR, raw data sets with no meta header — which is what modalities produce and what travels on the wire. 37 transfer syntaxes, implicit and explicit VR, little and big endian, deflated. 5,000+ standard tags and 10,500+ private vendor tags (GE, Siemens, Philips, Toshiba) with O(1) lookup.

Pixels. Decoding for JPEG Baseline, JPEG Extended, JPEG Lossless, JPEG-LS, and RLE Lossless, including multi-frame extraction from encapsulated pixel data. An encoder for RLE and JPEG-LS.

Networking. The complete DIMSE surface, as both client (SCU) and server (SCP): C-ECHO, C-STORE, C-FIND, C-MOVE, C-GET, all six N-DIMSE services, Storage Commitment, Modality Worklist, MPPS, and UPS. Full association negotiation — presentation contexts, extended negotiation, async operations, SCP/SCU role selection, user identity. TLS on both ends.

Clinical structures. Structured reports with coded concepts (SNOMED-CT, LOINC). Physiological waveforms — ECG, EEG — with QRS detection. Overlays and ROI analysis. 169 storage SOP classes.

Privacy. De-identification per PS3.15 Annex E, with the standard's profiles: basic, clean descriptors, clean graphics, and the retain-* options for longitudinal studies. It descends into sequences, which matters more than it sounds — a de-identified object that keeps its Referenced SOP Instance UIDs still links straight back to the original.

Internationalization. 30+ character encodings including ISO 2022, CJK, Cyrillic, Arabic and Hebrew. Text is decoded to UTF-8 on read, so you get strings rather than bytes you have to know how to interpret.

Why pure Go is the point

CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build
Enter fullscreen mode Exit fullscreen mode

That works. It produces one static binary with no shared library dependencies, which means a FROM scratch container, an ARM edge device sitting next to a modality, or a Lambda function — all from a laptop, with no cross-compilation toolchain and no C headers to hunt down.

The concurrency model is Go's rather than a wrapper over someone else's. The SCP spawns a goroutine per association. C-FIND results stream back on a channel instead of accumulating in a list. Everything takes a context.Context, so timeouts and graceful shutdown work the way you already expect them to.

The client

Roughly the shape of pynetdicom's AE().associate(), if you know that API:

scu := network.NewSCU(network.SCUConfig{
    CallingAE: "MY_APP",
    CalledAE:  "PACS",
    Address:   "pacs.hospital.com:11112",
})

if err := scu.Associate(ctx, nil); err != nil {
    log.Fatal(err)
}
defer scu.Release(ctx)

// Verification
if err := scu.Echo(ctx); err != nil {
    log.Fatal(err)
}

// Query — results stream as they arrive
results, _ := scu.Find(ctx, queryDataset)
for result := range results {
    fmt.Println(result.DataSet)
}
Enter fullscreen mode Exit fullscreen mode

The server

scp := network.NewSCP(network.SCPConfig{
    AETitle: "MY_SCP",
    Port:    11112,
})

scp.SetHandler(&network.StorageHandler{
    OnStore: func(ctx context.Context, sopClass, sopInstance string, ds *dataset.Dataset) uint16 {
        // Save to disk, a database, object storage, another PACS
        return network.StatusSuccess
    },
})

log.Fatal(scp.ListenAndServe(ctx))
Enter fullscreen mode Exit fullscreen mode

There are handler types for the common roles — echo, storage, query/retrieve, worklist — a composite handler when you want to mix them, and a BaseHandler to embed when you want full control over one message type and defaults for the rest.

Reading a file

dicomFile, err := filereader.ReadDICOMFile(file)
if err != nil {
    log.Fatal(err)
}

ds := dicomFile.GetDataset()
name, _ := ds.GetStringValue(tag.New(0x0010, 0x0010))
Enter fullscreen mode Exit fullscreen mode

Data sets are thread-safe. All mutable structures use sync.RWMutex, so concurrent readers with exclusive writers works without you building a lock layer on top.

It is also a CLI

Sixteen commands, covering the same ground as the DCMTK tools you may already be using:

# Files
go-dicom show patient.dcm              # display contents, sequences indented by depth
go-dicom info patient.dcm              # metadata
go-dicom convert patient.dcm out.json  # JSON, CSV or NIfTI
go-dicom codify patient.dcm            # generate Go source that rebuilds the data set
go-dicom tag-doc 0010,0010             # what does this tag mean

# Network
go-dicom echoscu pacs:11112
go-dicom storescu -aec PACS pacs:11112 study/*.dcm
go-dicom storescp -port 11112 -output ./received/
go-dicom findscu -patient-name "Smith*" -level STUDY pacs:11112
go-dicom movescu -dest MY_SCP -study 1.2.3.4 pacs:11112
go-dicom qrscp -port 11112             # a working query/retrieve archive
Enter fullscreen mode Exit fullscreen mode

That last one is worth pausing on. qrscp is backed by an on-disk instance store with a queryable index, so the library can be the archive rather than only talk to one. For a test environment, a research pipeline, or an edge cache in front of a real PACS, that removes an entire piece of infrastructure.

On trust

Medical imaging is a domain where a library quietly doing the wrong thing is genuinely dangerous, so I would rather over-share here than make claims you have to take on faith.

Interoperability is tested against other implementations, in CI. Every build runs against pynetdicom and dcmtk, and uses pydicom as the verifier rather than go-dicom's own reader. Correctness is measured against pydicom's test corpus — files I did not generate — not only against fixtures of my own making.

There is a conformance statement. CONFORMANCE.md follows the structure of PS3.2: SOP classes per role, transfer syntaxes negotiated versus those merely readable from a file, extended negotiation, configuration, character sets, enforced limits — and a plainly stated list of limitations.

The limitations are written down. JPEG 2000 is not encoded or decoded. The SCP side of asynchronous operations is not implemented. Sequence matching outside the standard's own matching keys is not supported. Where something is missing, the docs say so and say why, rather than leaving you to discover it in production.

Security is treated as part of correctness. Peer-supplied lengths are bounded, malformed PDUs cannot crash the server or trigger huge allocations, and received instances are no longer written to paths derived from unvalidated peer-supplied UIDs. Each of those was a real defect that got found and fixed; the SECURITY.md policy covers how to report more.

Every commit builds and passes independently, so the history is bisectable. gofmt, go vet and golangci-lint clean, tests under -race on Linux, macOS and Windows.

Where it fits

You need Today With go-dicom
DICOM in a Go service CGO binding to dcmtk Import a package
A container image Base image with C libs FROM scratch
ARM / edge deployment Cross-compilation toolchain GOARCH=arm64 go build
A test PACS Install and configure one go-dicom qrscp
Anonymize a study Python subprocess anonymize package

Try it

The repository is at github.com/amrshadid/go-dicom. MIT licensed, no dependencies outside the Go standard library and golang.org/x.

There are complete runnable examples in examples/ — reading, writing, modifying, sequences, image processing, and every networking pattern.

What I would find most useful right now is people running it against real equipment. If you have a PACS, a modality, or an archive that go-dicom cannot talk to, open an issue with the association details and I will fix it — that kind of report has been the source of nearly every meaningful improvement in the project so far.

And if it saves you from writing a DICOM parser this quarter, a star helps other people find it.

Top comments (0)