Data values (import + export)¶
DataValuesAccessor on Dhis2Client.data_values — both directions of /api/dataValueSets. stream uploads JSON / XML / CSV / ADX without buffering the whole payload in memory; export reads a form's values back as a typed DataValueSet; and for an export too large to hold, client.stream("GET", "/api/dataValueSets.json", sink, params=...) writes the body straight to storage. For the typed-list-of-DataValue case, see Aggregate data values; the streaming accessor is the large-payload path.
When to reach for it¶
- Importing a CSV / JSON file that's larger than the host's free RAM.
- Pipe-style imports where the source is an
AsyncIterable[bytes](e.g. a transform step that emits a row at a time). - Mixed-DataSet writes on DHIS2 v43 — the grouped path is the workaround for BUGS #35.
- Reading a form's values back after a write, or pulling one organisation unit's year for a report.
- Exporting a national year to a file or an object store without holding it in memory.
Worked example — stream a CSV file to DHIS2¶
from pathlib import Path
from dhis2w_core.client_context import open_client
from dhis2w_core.profile import profile_from_env
async with open_client(profile_from_env()) as client:
# `stream` takes a Path (or any AsyncIterable[bytes]) + a content type.
# The body is sent chunked; httpx2 never materialises the whole file.
envelope = await client.data_values.stream(
Path("./monthly-coverage-2026.csv"),
content_type="application/csv",
)
count = envelope.import_count()
if envelope.status == "OK" and count:
print(f"imported {count.imported} updated {count.updated} ignored {count.ignored}")
else:
print(f"status={envelope.status!r} message={envelope.message!r}")
Partly invalid imports: atomic_mode¶
stream(..., atomic_mode="ALL" | "OBJECT") forwards DHIS2's atomicMode switch. DHIS2 documents ALL as rejecting the whole import when any row is rejected and OBJECT as committing the rows that pass. On DHIS2 2.42 and 2.43 the switch has no observable effect on /api/dataValueSets: a payload with one valid row and one row that fails value-type validation commits the valid row under both modes and reports the other as ignored (BUGS.md #112). The parameter is forwarded so a build that honours it gets the documented behaviour; do not rely on ALL to keep a partly invalid import out.
A value equal to what the instance already holds also counts as ignored, so an unchanged re-import reads as ignored=N under either mode.
DHIS2 v42 answers any import that carries a conflict with HTTP 409, even when the other rows were committed; v43 answers 200 with the same WARNING envelope (BUGS.md #6). stream raises Dhis2ApiError on the 409, and the import summary is the exception's body:
from dhis2w_client import WebMessageResponse
from dhis2w_client.errors import Dhis2ApiError
try:
envelope = await client.data_values.stream(body, content_type="application/json", atomic_mode="OBJECT")
except Dhis2ApiError as exc:
if exc.status_code != 409 or not isinstance(exc.body, dict):
raise
envelope = WebMessageResponse.model_validate(exc.body)
count = envelope.import_count()
for conflict in envelope.conflicts():
print(f"{conflict.object}: {conflict.value}")
Worked example — export a form's values¶
async with open_client(profile_from_env()) as client:
# `data_set` / `period` / `org_unit` take one id or a sequence and repeat
# on the wire. A date range replaces an explicit period list;
# `children=True` includes the organisation units below `org_unit`.
data_value_set = await client.data_values.export(
data_set="BfMAe6Itzgt",
org_unit="DiszpKrYNg8",
start_date="2026-01-01",
end_date="2026-06-30",
)
for value in data_value_set.dataValues or []:
print(f"{value.dataElement} {value.period} {value.categoryOptionCombo} = {value.value}")
last_updated / last_updated_duration (2h, 1d) narrow to recently touched values; extra_params carries the rest of the endpoint's surface (idScheme, includeDeleted, dataElementGroup, ...).
Worked example — stream an export to a sink¶
export buffers the whole response. client.stream does not: it writes the body chunk by chunk to a pathlib.Path, to any object with .write(bytes) (sync or async), or to a callable that receives each chunk. It works for any endpoint, and client.analytics.stream_to is the Path-only convenience over it.
import io
from pathlib import Path
params = {
"dataSet": ["BfMAe6Itzgt"],
"orgUnit": ["ImspTQPwCqd"],
"children": "true",
"startDate": "2025-01-01",
"endDate": "2025-12-31",
}
async with open_client(profile_from_env()) as client:
# To a file. Parent directories are created; the file is never assembled in memory.
written = await client.stream("GET", "/api/dataValueSets.json", Path("./exports/2025.json"), params=params)
# To anything with .write(bytes): an open file, an upload stream, a BytesIO.
buffer = io.BytesIO()
await client.stream("GET", "/api/dataValueSets.json", buffer, params=params)
# To a callable: count, hash, or forward each chunk. Async callables are awaited.
async def forward(chunk: bytes) -> None:
await queue.put(chunk)
await client.stream("GET", "/api/dataValueSets.json", forward, params=params, chunk_size=64 * 1024)
A 4xx / 5xx raises AuthenticationError or Dhis2ApiError before anything is written; a Path sink is not created when the request fails.
Worked example — typed DataValue write (small batch)¶
from dhis2w_client import DataValue
values = [
DataValue(
dataElement="fbfJHSPpUQD",
period="202604",
orgUnit="ImspTQPwCqd",
categoryOptionCombo="HllvX50cXC0",
attributeOptionCombo="HllvX50cXC0",
value="42",
),
]
async with open_client(profile_from_env()) as client:
# `import_grouped_by_dataset` is the cross-version write path
# (required on v43 for DEs in multiple DataSets — BUGS #35).
# Returns `list[WebMessageResponse]` — one envelope per DataSet group.
envelopes = await client.data_values.import_grouped_by_dataset(values)
for env in envelopes:
count = env.import_count()
print(f" status={env.status} imported={count.imported if count else '?'}")
Related examples¶
examples/client/stream_data_values.py— four streaming shapes (bytes, sync generator, Path/CSV, 1000-row file with timing).examples/client/aggregate_bulk_grouped.py— the grouped path against a v43 stack.examples/client/data_values_import_atomic.py— the same partly invalid payload underALLandOBJECT.examples/client/data_values_export.py—exportas a typed envelope, thenclient.streamto a Path, a BytesIO, and a callable.
data_values
¶
Streaming data-value-set import — client.data_values.stream.
DHIS2's POST /api/dataValueSets accepts JSON, XML, CSV, and ADX payloads.
For a 100k-row push (a typical month-end aggregate upload), buffering the
whole body in Python memory before the POST is the thing to avoid:
- A 100k-row JSON payload sits at ~30-60 MB on the wire, and the Python parsed shape is 3-5x that — so ~150 MB resident just to stage the request.
- The same payload on CSV is ~8 MB; XML is in between.
client.data_values.stream(source, content_type) feeds httpx2's chunked
transfer encoding directly, so the payload never sits fully in memory
on the client side. The server consumes it as it arrives.
source accepts any of:
pathlib.Path— opens the file and chunks it through.bytes/bytearray— single-shot for callers who already have the body assembled but want the typedWebMessageResponseenvelope.Iterable[bytes]/AsyncIterable[bytes]— pass-through for generators that build the body on the fly (e.g. DB-row → CSV line).- File-like with
.read(size) -> bytes(sync or async) — adapted to a chunk iterator.
Supported content_type values map to the DHIS2-accepted MIME types:
application/json(default)application/xmlapplication/csv(also accepted:text/csv)application/adx+xml
Classes¶
DataValuesAccessor
¶
Dhis2Client.data_values — streaming uploads to /api/dataValueSets.
Stateless wrapper over the streaming POST path. Stay here for the large
import cases; use dhis2w_core.plugins.aggregate.service.push_data_values
when the payload is already a small in-memory list of typed data values.
Source code in packages/dhis2w-client/src/dhis2w_client/v43/data_values.py
52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 | |
Methods:¶
__init__(client)
¶
stream(source, *, content_type='application/json', dry_run=False, preheat_cache=True, import_strategy=None, atomic_mode=None, id_scheme=None, data_element_id_scheme=None, org_unit_id_scheme=None, skip_audit=False, async_job=False, chunk_size=_DEFAULT_CHUNK_SIZE)
async
¶
Stream source to POST /api/dataValueSets and return the typed envelope.
content_type picks which DHIS2 parser handles the body (JSON / XML /
CSV / ADX). Every param from the standard /api/dataValueSets surface
is forwarded via query string:
dry_run→dryRun=true: validate without committing.preheat_cache=False→preheatCache=false.import_strategy:CREATE/UPDATE/CREATE_AND_UPDATE/DELETE.atomic_mode:ALLrejects the whole import if any row is rejected;OBJECTcommits the rows that pass and skips the rest. DHIS2 defaults toALLwhen unset.id_scheme/data_element_id_scheme/org_unit_id_scheme: pick the identifier scheme for the payload (UID/CODE/NAME/ ...).skip_audit=True→skipAudit=true.async_job=True→async=true: DHIS2 queues the import as a job and the returned envelope carriesresponse.jobType/response.id. Poll withclient.tasks.await_completion(envelope.task_ref()).
Returns a WebMessageResponse. For synchronous imports,
envelope.import_count() gives ImportCount.imported / updated /
ignored / deleted; envelope.conflicts() lists per-row rejections.
Async imports return the task-ref envelope — poll it to completion
to get the final report from DHIS2.
Source code in packages/dhis2w-client/src/dhis2w_client/v43/data_values.py
export(*, data_set=None, period=None, org_unit=None, children=False, start_date=None, end_date=None, last_updated=None, last_updated_duration=None, extra_params=None)
async
¶
GET /api/dataValueSets and return the parsed DataValueSet envelope.
The read side of the import handled by stream(). data_set, period,
and org_unit each accept a single id or a sequence of ids, and repeat
as dataSet=/period=/orgUnit= query params the way DHIS2 expects.
children=True->children=true: include the org units below eachorg_unitin the export.start_date/end_date(YYYY-MM-DD): export by date range instead of byperiod.last_updated(YYYY-MM-DD) /last_updated_duration(e.g.2h,1d): export only values touched since then.extra_paramscovers the rest of the surface (idScheme,dataElementIdScheme,includeDeleted,dataElementGroup,orgUnitGroup, ...). Pass a flat mapping or a list of 2-tuples.
Buffers the whole response into a typed DataValueSet. For a large
export, stream it straight to storage with
client.stream("GET", "/api/dataValueSets.json", sink, params=...)
instead of materialising every row in memory.
Raises Dhis2ApiError on 4xx / 5xx.
Source code in packages/dhis2w-client/src/dhis2w_client/v43/data_values.py
import_grouped_by_dataset(values, *, chunk_size=1000, force=False, skip_audit=False)
async
¶
Import typed DataValues grouped by dataset — BUGS.md #35 workaround for v43.
v43 added auto-target dataset detection on POST /api/dataValueSets
(DefaultDataEntryService.autoTargetDataSet). When a posted DataValue's
DataElement is referenced by 2+ DataSets, v43 aborts the entire chunk
with 409 E8002 Data set detection failed. v41 + v42 silently picked
one matching dataset and imported the row.
Workaround: pre-fetch the DataElement → DataSet membership map, group
the input values by their DataSet (lexicographically-first DataSet id
when a DE belongs to multiple — deterministic across runs), and POST
each group with an explicit envelope {"dataSet": "<id>", "dataValues":
[...]}. This shape is accepted by every DHIS2 major.
Splits each per-dataset group into chunk_size rows per POST so the
body stays inside httpx2's 300 s read timeout. Returns one
WebMessageResponse per chunk; callers that want aggregate counts
should walk import_count() across the list and sum.
Skips values whose DataElement isn't in any DataSet (counted in the
first response's import-count under ignored).