Cuando un Bundle deja de ser suficiente
La API REST de FHIR está construida en torno a un paciente cada vez. Buscar las condiciones de un paciente, leer un encuentro, registrar una observación: cada interacción es pequeña, síncrona y devuelve un Bundle. Ese modelo encaja muy bien con un clínico mirando una historia y muy mal con una pregunta del tipo «dame todas las Observation de los 40.000 pacientes de esta población de medida de calidad».
Bulk Data Access —informalmente «Flat FHIR»— es la especificación escrita para esa segunda pregunta. Es una guía de implementación de HL7, publicada aparte de la especificación núcleo y construida sobre el patrón de petición asíncrona de FHIR. Define una operación $export que lanza un trabajo de larga duración, un protocolo de sondeo para seguirlo, un manifiesto JSON que describe los resultados y un formato de salida que deliberadamente no es un Bundle: JSON delimitado por saltos de línea, un recurso por línea.
Esta guía recorre el flujo completo: la petición inicial, los parámetros que merece la pena conocer, los estados de sondeo, los campos del manifiesto y el manejo práctico de ficheros que pueden ser muy grandes. Una vez tengas NDJSON en disco puedes abrirlo directamente en el Explorador de Bundles FHIR, que detecta NDJSON además de Bundles y recursos sueltos.
Tres roles, un flujo
La guía separa el lado proveedor en tres componentes, y saber cuál es cuál explica varios detalles que de otro modo resultan extraños. Hay un servidor de autorización FHIR que emite tokens de acceso; un servidor de recursos FHIR que acepta la petición inicial y ofrece el estado del trabajo y el manifiesto de finalización; y un servidor de ficheros de salida que sirve los datos. El servidor de ficheros puede formar parte del servidor FHIR o ser completamente independiente; muy a menudo es almacenamiento de objetos con URLs firmadas. Al otro lado está el cliente Bulk Data, que pide tokens y descarga ficheros.
Se espera que la autorización siga el perfil SMART Backend Services Authorization: el cliente se registra, presenta una petición de token firmada y recibe un token de acceso de corta vida. Es autorización máquina a máquina sin usuario en el bucle, el modelo correcto para una extracción analítica nocturna. La guía exige TLS 1.2 o posterior en todos los intercambios que describe.
La petición inicial
Una exportación empieza con una única petición a uno de tres endpoints, que solo se diferencian en el alcance:
[base fhir]/Patient/$export— todos los pacientes que el cliente esté autorizado a ver.[base fhir]/Group/[id]/$export— los miembros de un Group definido. Cómo se definen los Groups queda a criterio de cada implementación; un listado de asegurados de un pagador importado en un HIS es un ejemplo típico.[base fhir]/$export— exportación a nivel de sistema, incluidos datos no asociados a ningún paciente. Cubre casos como copiar un servidor o extraer terminología.
Un servidor debe admitir GET para estos endpoints y puede admitir POST con los parámetros en un recurso Parameters. Importan dos cabeceras. Accept especifica el formato del OperationOutcome opcional de la respuesta inicial; actualmente solo se admite application/fhir+json. Prefer debería llevar respond-async, indicando al servidor que procese de forma asíncrona; si se omite, el servidor puede devolver un error o proceder como si se hubiera indicado.
Las versiones más recientes de la guía añaden un segundo valor opcional de Prefer, separate-export-status. Sin él, el estado HTTP de una petición de estado refleja el trabajo de exportación. Con él, la petición de estado devuelve su propio estado HTTP y el del trabajo aparece en una cabecera X-Export-Status. Existe porque intermediarios y librerías cliente maltratan con frecuencia un 202 que en realidad habla de un trabajo y no de la petición.
Parámetros que conviene conocer
La petición inicial admite un conjunto de parámetros de consulta, de los cuales unos pocos hacen casi todo el trabajo en la práctica:
| Parámetro | Para qué sirve |
|---|---|
_outputFormat | Formato de los ficheros generados. Por defecto application/fhir+ndjson. Los servidores deben admitir NDJSON y aceptar también las abreviaturas application/ndjson y ndjson. |
_since | Un instant de FHIR. Incluye recursos cuyo estado cambió después de ese momento, normalmente comparando con Resource.meta.lastUpdated. Es la base de las extracciones incrementales. |
_type | Tipos de recurso separados por comas a los que restringir la exportación. Si se omite, el servidor devuelve todo lo que cubra la autorización del cliente. |
_typeFilter | Una consulta de búsqueda FHIR aplicada por tipo de recurso, p. ej. MedicationRequest?status=active. Puede repetirse; varios filtros para el mismo tipo se combinan con OR. |
_elements | Marcado como experimental. Pide al servidor omitir elementos no obligatorios que no se listen. Los servidores deberían etiquetar el resultado como SUBSETTED. |
includeAssociatedData | Marcado como experimental. Controla la inclusión de recursos Provenance asociados mediante valores como LatestProvenanceResources. |
Dos advertencias de la guía merecen interiorizarse. Primera: el soporte de _typeFilter es opcional, y los clientes deberían ser robustos ante servidores que lo ignoren; nunca des por hecho que el filtrado ocurrió. Segunda: usa _typeFilter y no _since cuando quieras filtrar por fechas clínicas. _since opera sobre la fecha de modificación del recurso, que puede no guardar ninguna relación con cuándo ocurrió el evento clínico: un encuentro de 2019 editado la semana pasada tiene un meta.lastUpdated reciente.
Los servidores que no puedan soportar un parámetro solicitado deberían devolver un error con un OperationOutcome para que el cliente reenvíe la petición sin él. Una cabecera Prefer: handling=lenient pide al servidor que procese la petición de todos modos en lugar de fallar.
El intercambio asíncrono
Una petición inicial correcta devuelve 202 Accepted con una cabecera Content-Location que contiene la URL absoluta de un endpoint de estado: la ubicación de sondeo. El cuerpo puede llevar opcionalmente un OperationOutcome. Todo lo demás ocurre contra esa URL.
El cliente sondea con GET [ubicación de sondeo], usando una cabecera Accept de application/json. Hay tres desenlaces posibles:
- En curso — 202 Accepted, opcionalmente con una cabecera
X-Progresscon un texto libre de menos de 100 caracteres («50% complete», «in progress») y una cabeceraRetry-After. - Error — un estado 4xx o 5xx, con un OperationOutcome en el cuerpo bajo
application/fhir+json. - Completado — 200 OK,
Content-Type: application/json, una cabeceraExpiresque indica cuándo dejarán de estar disponibles los ficheros, y el manifiesto de salida en el cuerpo.
La etiqueta de sondeo está especificada, no meramente sugerida. Los clientes deberían usar retroceso exponencial. Los servidores deberían enviar Retry-After como un retardo en segundos o como una fecha HTTP, y los clientes deberían respetarlo. Un servidor que detecte sondeos demasiado frecuentes debería responder 429 Too Many Requests con Retry-After, y si el cliente insiste puede terminar la sesión. Un bucle de sondeo agresivo no solo es de mala educación: puede costarte la exportación.
Un cliente también puede enviar DELETE [ubicación de sondeo] para cancelar una exportación en curso, o para señalar tras la descarga que el servidor puede limpiar los ficheros. El servidor responde 202 Accepted, y las peticiones posteriores a la ubicación de sondeo deben devolver 404 con un OperationOutcome.
Una sutileza sobre el fallo parcial: aunque algunos recursos solicitados no puedan exportarse, la operación global puede seguir considerándose correcta. En ese caso el servidor usa un estado 200 y rellena el array error del manifiesto con ficheros que describen lo ocurrido. Dónde trazar la línea entre éxito parcial y fallo total queda a criterio del implementador, así que un 200 no significa por sí solo que esté todo lo que pediste.

El manifiesto de salida
El manifiesto es un objeto JSON plano —no un recurso FHIR— y es el mapa de tus datos. Sus campos:
transactionTime(obligatorio) — la hora del servidor en que se ejecutó la consulta. La respuesta no debería incluir recursos modificados después de ese instante, y debe incluir los que coincidan modificados hasta ese instante inclusive. Es el valor que conservas y reutilizas como_sinceen la siguiente ejecución incremental.request(obligatorio) — la URL completa de la petición inicial original.requiresAccessToken(obligatorio) — si descargar los ficheros requiere la misma autorización que la propia$export. Estruecuando ambos servidores usan tokens bearer OAuth 2.0, y puede serfalseen servidores de ficheros con otros esquemas, como URLs firmadas de almacenamiento de objetos. Cuando esfalse, el cliente no debe enviar su token de acceso a esas URLs.output(obligatorio) — array de ficheros, uno por fichero generado, cada uno conurl, untypeque nombra el tipo de recurso contenido y uncountopcional. Si no hubo coincidencias, el servidor debería devolver un array vacío.deleted(opcional) — ficheros que listan recursos eliminados desde la marca_since. Cada línea es un Bundle de transacción cuyas entradas llevanrequest.methodigual aDELETEy unarequest.url. Los recursos que aparecen aquí no deben aparecer también enoutput.error(obligatorio) — ficheros de recursos OperationOutcome que describen errores, avisos y mensajes informativos. Array vacío cuando no hay nada que informar.extension(opcional) — objeto reservado para extras específicos del servidor, como una clave de descifrado para salida cifrada.
El array deleted es la pieza que más se pasa por alto en una primera implementación. Sin procesarlo, un pipeline incremental acumula registros que ya no existen en origen —pacientes fusionados, observaciones registradas por error— y tu almacén se desvía poco a poco de la fuente de verdad de una forma muy difícil de detectar a posteriori.
Descargar los ficheros
Los ficheros se recuperan con GET contra las URLs del manifiesto, dentro de la ventana que indica la cabecera Expires. Si requiresAccessToken es true, la petición debe llevar un token de acceso válido. La cabecera Accept es opcional y por defecto vale application/fhir+ndjson; una respuesta correcta lleva un Content-Type acorde al formato entregado, que para NDJSON debe ser application/fhir+ndjson.
Merece la pena comprimir. Los clientes deberían enviar Accept-Encoding incluyendo gzip, y los servidores deben entregar los ficheros sin comprimir, con gzip o con otro formato de esa cabecera, señalando la elección con Content-Encoding. El JSON de FHIR es extremadamente repetitivo —los mismos nombres de elemento en cada línea— así que gzip suele lograr una reducción muy notable, y en una exportación de varios gigabytes eso es la diferencia entre una transferencia que termina y una que expira.
Los datos exportados incluyen solo la versión más reciente de cada recurso, salvo que el cliente pida explícitamente otra cosa de una forma que el servidor admita. Si tu caso de uso necesita historial, la exportación masiva no es el instrumento adecuado.
¿Por qué NDJSON y no un Bundle?
Esta es la decisión de diseño que define toda la especificación, y se reduce a la memoria.
Un Bundle es un único documento JSON. Para leer la última entrada hay que haber analizado el documento entero, lo que significa que un parser convencional mantiene toda la exportación en memoria a la vez. Eso es perfectamente razonable para una página de resultados e imposible para cien millones de observaciones. Peor aún, es todo o nada: un solo byte corrupto en cualquier punto y el análisis completo falla, llevándose por delante todos los recursos válidos.
NDJSON invierte ambas propiedades. Cada línea es un documento JSON completo e independiente: un recurso FHIR, sin envoltorio y sin comas entre registros. El consumidor lee una línea, la analiza, la procesa, la descarta y sigue, con memoria constante sea cual sea el tamaño del fichero. Una línea corrupta te cuesta esa línea. Los ficheros pueden dividirse y procesarse en paralelo simplemente cortando por saltos de línea, que es la razón por la que todas las herramientas de datos a gran escala, de jq a Spark o BigQuery, ingieren NDJSON de forma nativa. Y añadir al final es trivial, así que un servidor puede emitir salida a medida que la produce.
La contrapartida es real y conviene enunciarla: NDJSON no tiene sobre. No hay total, ni enlaces de paginación, ni Bundle.type, y sobre todo no hay fullUrl por entrada. Todo ese contexto se traslada al manifiesto y al nombre de los ficheros. También implica que las referencias resuelven de otra manera: sin fullUrl, una referencia relativa como Patient/123 debe interpretarse contra la base del servidor que exportó, y no contra metadatos por entrada. Si esa distinción te resulta nueva, Cómo funcionan las referencias en FHIR cubre las reglas de resolución al completo, y Qué es un Bundle FHIR explica el sobre que NDJSON descarta a propósito.
NDJSON no es un invento de FHIR. Es una convención comunitaria sencilla —cada línea es un valor JSON válido, las líneas se separan con \n y el texto es UTF-8— que FHIR adoptó con su propio tipo de medio, application/fhir+ndjson, en lugar de reinventarla.
Manejar exportaciones grandes en la práctica
Unos pocos hábitos separan los pipelines que sobreviven al contacto con un sistema de salud real de los que no.
- Nunca cargues un fichero entero en memoria. Procesa línea a línea. El formato se eligió precisamente para permitirlo; usarlo con un parser de documento completo tira por la borda su única ventaja.
- Acota la petición. Usa
_typepara pedir solo lo que necesitas. Exportarlo todo y descartar casi todo desperdicia horas de servidor y tu ancho de banda, y Bulk Export es explícitamente una operación intensiva que los servidores pueden limitar. - Conserva
transactionTime. Es la marca de agua correcta para la siguiente ejecución incremental. No uses tu propio reloj: la deriva de relojes y los trabajos largos crearán huecos. - Procesa el array
deleted. De lo contrario tu copia se desincroniza de forma permanente. - Descarga dentro de la ventana
Expiresy vuelve a pedir el manifiesto si los enlaces caducaron: los servidores pueden emitir URLs nuevas y una caducidad nueva. - Trata los ficheros como inmutables una vez recuperados. La guía indica que los ficheros de salida no deben alterarse una vez incluidos en un manifiesto entregado, lo que los hace seguros para calcular sumas de verificación y cachear.
- Muestrea antes de construir. Coge las primeras cientos de líneas de un fichero e inspecciona la forma real de los datos. Qué elementos vienen poblados, qué codificaciones se usan, cómo se escriben las referencias: todo eso varía enormemente entre implementaciones y sale mucho más barato descubrirlo ahora que después de escribir la transformación.
Inspeccionar una muestra sin subir datos de paciente
Ese último hábito es donde un explorador local se gana su sitio. El Explorador de Bundles FHIR detecta NDJSON automáticamente, así que un fragmento de un fichero de exportación se pega tal cual, sin conversión. Informa del recuento por tipo de recurso, construye un mapa de referencias y aplana el tipo que elijas en una tabla exportable a CSV, con los valores repetidos unidos en una celda con punto y coma o expandidos a una fila por repetición, de modo que nada se pierde en silencio. Las rutas se muestran sin índices de array, así que name[0].given[0] y name[1].given[0] caen ambas en una única columna name.given.
Está acotado a propósito: 25 MB de entrada y 20.000 recursos, y cuando alcanza un límite informa exactamente de cuántos recursos no se analizaron en lugar de truncar sin avisar. Una línea defectuosa en NDJSON se convierte en una incidencia con su número de línea en vez de descartar las otras 19.999 correctas. Las referencias se resuelven solo contra lo que has pegado, nunca descargando nada de la red, porque los ficheros de exportación son datos de paciente. Todo se queda en tu navegador. Es una herramienta de exploración, no un validador; para comprobar conformidad, usa el Validador de Recursos FHIR.
Conclusión
Bulk Data Export existe porque el movimiento de datos a escala poblacional tiene restricciones distintas al acceso a nivel de historia clínica. El flujo es consistente y vale la pena memorizarlo: autoriza vía SMART Backend Services, lanza $export a nivel de sistema, paciente o grupo con Prefer: respond-async, toma la URL de sondeo de Content-Location, sondea con retroceso hasta el 200 OK, lee el manifiesto y descarga los ficheros que lista, respetando requiresAccessToken, procesando deleted y conservando transactionTime para la próxima vez.
La salida es NDJSON y no un Bundle por una razón que pesa más que todas las demás: el procesamiento en flujo. Un recurso por línea significa memoria constante, procesamiento en paralelo y degradación elegante ante un registro corrupto, propiedades que ningún formato de documento único puede ofrecer. Entiende qué te daba el sobre y adónde se ha trasladado ese contexto, muestrea tus ficheros localmente en el Explorador de Bundles FHIR antes de escribir la transformación, y una exportación masiva pasará de ser un proyecto de investigación a un trabajo por lotes rutinario.