↓ Skip to main content

What C-FIND Can and Can't Tell You About an Archive

John Sole
Author
John Sole
I help health systems implement imaging systems and fit them into the workflows already in place. DICOM, HL7, IHE, PACS and VNA, with SQL and Python as needed.

Every migration, every archive assessment, and every “how big is this thing, really?” conversation I’ve been part of runs into the same wall early: the organization needs to know what’s in the archive, and the only access anyone will grant is a DICOM port.

No database login. No vendor report anyone trusts. Sometimes not even a conformance statement that matches the installed version. Just an AE title, an IP address, and permission to query.

That makes C-FIND the instrument. It’s a good one, but it was designed to find a patient’s studies for a reading workstation, not to inventory a petabyte. If you use it for the second job, you need to know exactly where the standard stops guaranteeing answers and starts leaving them to the vendor. This is a walk along that boundary, with the standard open beside me.

The model you are querying
#

DICOM Query/Retrieve is defined in PS3.4, Annex C. An archive that supports it exposes one or more information models. The two that matter in practice are Patient Root and Study Root, and both are hierarchies of the same four levels:

flowchart TD
    P["PATIENT
Patient ID (0010,0020)"] ST["STUDY
Study Instance UID (0020,000D)"] SE["SERIES
Series Instance UID (0020,000E)"] IM["IMAGE
SOP Instance UID (0008,0018)"] P --> ST --> SE --> IM classDef lvl fill:#1e293b,stroke:#64748b,color:#e2e8f0; class P,ST,SE,IM lvl;

Each level has a unique key (shown above), a short list of required keys the archive must be able to match on and return, and a longer list of optional keys it may or may not support. The standard spells these out in Section C.6. Study Root starts the hierarchy at STUDY and folds the patient attributes into that level, which is why most tools default to it. Patient Root is common on older archives; the method below works the same there, with one more level to walk and Patient ID as the key for it.

The word hierarchical is doing real work. A baseline query asks about one level at a time, and to go deeper you must supply the unique keys for every level above. To learn anything about the series in a study, you first have to have found the study. The standard defines relational queries that let you skip levels, but they’re an optional extension negotiated at association time; assume you don’t have them until the conformance statement and a live test both say you do.

How a query actually proceeds
#

Here is the shape of one STUDY-level query. Everything in it is the standard’s behavior, not a vendor’s.

sequenceDiagram
    participant SCU as Your tool (SCU)
    participant SCP as Archive (SCP)
    SCU->>SCP: A-ASSOCIATE-RQ (Study Root FIND SOP Class)
    SCP-->>SCU: A-ASSOCIATE-AC
    SCU->>SCP: C-FIND-RQ
Level=STUDY, StudyDate=20240301-20240331,
ModalitiesInStudy="", NumberOfStudyRelatedInstances="" loop one response per matching study SCP-->>SCU: C-FIND-RSP, status Pending (FF00 or FF01)
+ identifier with return keys filled in end SCP-->>SCU: C-FIND-RSP, status Success (0000) SCU->>SCP: A-RELEASE-RQ SCP-->>SCU: A-RELEASE-RP

Two details in that exchange decide whether your inventory is trustworthy.

First, the identifier you send is both the question and the answer template. An attribute with a value is a matching key (“only studies in March 2024”). An attribute sent with zero length is a return key (“and tell me the modalities and instance count for each”). You get back only what you asked for, so an inventory tool has to know in advance every attribute it wants.

Second, the status on each pending response carries information most tools throw away. FF00 means “here’s a match.” FF01 means “here’s a match, but one or more of the optional keys you asked for are not supported” (PS3.4, Table C.4-1). An archive that returns FF01 is telling you, on every response, that part of your question went unanswered. The reverse is not true: plenty of archives return FF00 and simply leave unsupported attributes empty, so an empty return attribute on a FF00 response is ambiguous. Record the status anyway. FF01 is a gift when you get it, and its absence is not a clean bill of health.

What the standard guarantees
#

At the STUDY level in Study Root, the required keys are short, and they are the reason C-FIND is useful for this at all. Every conformant archive will match and return:

AttributeTagWhat it buys you
Study Date(0008,0020)Range matching, so you can walk the archive by day, month, or year
Study Time(0008,0030)Range matching within a day
Accession Number(0008,0050)Reconciliation against the RIS or EHR
Patient’s Name(0010,0010)Wildcard matching
Patient ID(0010,0020)The key you’ll join on
Study ID(0020,0010)Rarely useful; often blank or duplicated
Study Instance UID(0020,000D)The unique key, and the only durable identity a study has

Range matching on dates is what makes enumeration possible. Query StudyDate = 20240301-20240331, collect the UIDs, move to April. Slow, deterministic, and it works against any archive that claims Q/R conformance.

What the standard leaves optional
#

Now the part that matters for sizing. The attributes you actually want for an inventory are all optional at the STUDY level:

AttributeTagWhy you want itWhy you can’t count on it
Modalities in Study(0008,0061)Mix of CT, MR, CR, USOptional; some archives populate it only for studies ingested after the feature was enabled
Number of Study Related Series(0020,1206)Study shapeOptional; often computed at ingest and never recomputed
Number of Study Related Instances(0020,1208)The number everyone wantsOptional; same problem, and it drifts
Study Description(0008,1030)Human-readable contextOptional, and free text

“Optional” in DICOM means the archive may support it, and if it does, the conformance statement says so. It does not mean “usually works.” The pattern I see most often with the counts is that they’re derived values populated when the study arrived, and nothing re-derives them after a series is deleted, a study is split, or a migration lands only part of the images. The count you get back is the count the archive believed at some point in the past, and after a messy history it can be off by a large fraction.

Then there’s the list of things no C-FIND attribute exists for at all. You cannot ask an archive:

  • How many bytes a study occupies. There is no size attribute at any level. Rows, Columns, Bits Allocated, and Number of Frames let you compute it, but they’re IMAGE-level attributes that the standard doesn’t list among the IMAGE-level query keys; an archive may support them as additional optional keys, and the conformance statement is where you find out.
  • When it received the study. Study Date is the clinical date, set by the modality. The archive’s ingest timestamp isn’t a query key. A study performed in 2009 and migrated in 2023 queries as 2009.
  • Where it is stored. Online, nearline, tape, cloud tier, deleted-but-indexed: invisible.
  • What you didn’t get. C-FIND returns matches. It never returns “and here are the studies I didn’t return because I cap results.” Caps are real and often undocumented. The first sign is usually that every window of your enumeration returns exactly the same round number.

Sizing an archive anyway
#

Knowing the limits, here’s the method I use when C-FIND is all I have: three passes, each stepping down a level only where the one above left a gap.

flowchart LR
    A["Pass 1
STUDY level
by date range"] --> B{"Counts and
modalities
returned?"} B -- "yes" --> D["Record study row"] B -- "no, FF01, or blank" --> C["Pass 2
SERIES level
per study UID"] C --> E{"Instance count
per series
returned?"} E -- yes --> D E -- no --> F["Pass 3
IMAGE level
per series UID"] F --> D D --> G["Size estimate
from counts × typical
object size per modality"] classDef pass fill:#1e293b,stroke:#64748b,color:#e2e8f0; class A,C,F pass;

Pass 1 walks the calendar at STUDY level in windows small enough to stay under any cap (I start at one day and widen if the archive is thin), asking for the required keys plus the optional counts and Modalities in Study, and recording the status on every response. Pass 2 queries SERIES for every study where the counts came back blank or the status was FF01; Modality (0008,0060) is required at this level, so the modality mix becomes trustworthy here even when Modalities in Study wasn’t. Pass 3 enumerates instances only where Pass 2 still left counts unknown. It’s expensive, so it’s the exception.

Then estimate: instances times a typical object size per modality lands within a usable tolerance of total storage, with the error dominated by the handful of large multi-frame and tomosynthesis objects, which you can go measure individually. Present it as what it is: an estimate derived from counts, with the per-modality assumptions written next to it.

A few habits make this robust. Log every response status. Log the association parameters; the accepted presentation contexts are a conformance statement you can actually trust. Re-run one early window at the end to see whether the archive changed under you. Treat any window that returns a suspiciously round count as a cap, not a fact.

What’s missing from this post
#

A worked run. I’ve described the method but not shown it with numbers: window sizes, response counts, how far the estimate landed from the truth. That’s deliberate; the archives I’ve run it against belong to clients, and the right way to show it is against an archive I own. That’s the next post, with the queries and the results in it.

If you’ve hit limits in C-FIND I haven’t mentioned, I’d like to hear about them: [email protected].

Sources
#