Sidney Bissoli
    • Português
  • MCP servers
  • R packages
  • Projects
  • Writing
  • Publications
  • About me

On this page

  • Coverage
  • Same reader, two philosophies
  • One download in both: SIH, Roraima, competence 2023-01
    • Timing
  • What comes with the data: provenance
  • Lazy: a year without downloading it
  • SIM: deaths, Acre 2022
  • SINASC: births, Acre 2022
  • Where the bytes come from
  • Other routes
  • Which one
  • Reproducing

Other Formats

  • Github (GFM)

healthbR or microdatasus? The two R packages for DATASUS, side by side on the same download

SIM, SINASC and SIH read by both packages: what each returns, how long it takes, where codes become labels — and one trap on each side

Public health
DataSUS
R
healthbR
microdatasus
Author

Sidney Bissoli

Published

September 5, 2026

Published 5 September 2026. Every number here was measured that day, in one R session, with healthbR 0.3.1 (development version), microdatasus 3.0.0, arrow 25.0.0, dplyr 1.2.1 and R 4.6.1 on Windows, over a residential connection in Brazil. The code of each measurement is pasted here and in the package’s vignette; nothing is illustrative.

If you read Brazilian public-health microdata in R — deaths from SIM, live births from SINASC, hospital admissions from SIH, all published by DATASUS, the Ministry of Health’s informatics department — you have two packages to choose from. microdatasus, by Raphael Saldanha, described in Saldanha et al. (2019), is the usual answer to “how do I get DATASUS into R”. healthbR is mine. They overlap on six DATASUS systems and differ in almost everything they do with the bytes after the download.

This post puts the two side by side on the same download, three times: admissions in Roraima for billing competence 2023-01, deaths in Acre in 2022, births in Acre in 2022. I show what each returns, how long it takes, and where the choice changes what a script computes. The point is not to win a comparison; it is that whoever reaches DATASUS through R knows what they are choosing.

Coverage

healthbR microdatasus 3.0.0
SIM (mortality) sim_data(): DO, 1996–2024 DO, DOFET, DOEXT, DOINF, DOMAT
SINASC (live births) sinasc_data(): 1996–2024 SINASC
SIH (admissions) sih_data(): RD (reduced AIH), 2008–2026 RD, RJ, SP, ER
SIA (outpatient) 13 file types, 2008–2026 12 file types
CNES (facilities) 13 file types, 2005–2026 13 file types
SINAN (notifiable diseases) 31 diseases, 2007–2026 8 diseases
SI-PNI (vaccination) 1994–2026 —
SISAB (primary care) yes —
Surveys VIGITEL, PNS, PNAD Contínua, POF, Censo denominators —
Agencies ANS, ANVISA —
Auxiliary tables *_dictionary(), *_variables() per module SIGTAP, CADGER, tabMun, tabCBO, tabOcupacao, tabNaturalidade

microdatasus goes deeper into SIM and SIH (the fetal, external-cause, infant and maternal SIM extracts; the RJ, SP and ER SIH files) and ships the SIGTAP procedure table. healthbR goes wider: more SINAN diseases, SI-PNI, SISAB, the household surveys and the agencies, under one *_years() / *_data() / *_dictionary() API.

Same reader, two philosophies

Both decompress the Ministry’s .dbc files (DBF compressed with PKWare DCL) with the same C code: microdatasus 3.0.0 dropped its read.dbc dependency and adopted healthbR’s vendored implementation — it is in its NEWS, with thanks. The bytes are identical; the difference starts right after.

healthbR keeps the codes and types the columns. SEXO stays 1/3, MORTE stays 0/1, DT_INTER becomes a Date, VAL_TOT a double, IDADE an integer. Labels live in sih_dictionary(), sim_dictionary(), sinasc_dictionary(), joined when you want them.

microdatasus separates fetching from processing. fetch_datasus() returns every column as character, as the DBF has it. process_sih(), process_sim(), process_sinasc() replace codes with labels (SEXO becomes "Masculino"/"Feminino", MORTE becomes "Não"/"Sim"), convert dates, split the SIM age field into one column per unit and, with municipality_data = TRUE, join municipality names and coordinates. The result is a tibble of character columns.

Neither is wrong. Labels are what you want in a report table; codes are what you want to join, compare across years, or model. The trap is mixing them: after process_sih(), MORTE == 1 is FALSE on every row.

One download in both: SIH, Roraima, competence 2023-01

Roraima is the smallest SIH file; January 2023 has 4,734 admissions. Both reads start from an empty cache.

library(healthbR)
library(microdatasus)
library(dplyr)

rr_h <- sih_data(year = 2023, month = 1, uf = "RR")             # healthbR
#> ℹ Reading SIH data from R2: 2023/01 (1 UF(s))...
dim(rr_h)
#> [1] 4734  116
table(vapply(rr_h, function(x) class(x)[1], ""))
#> character      Date   integer   numeric
#>       101         3         7         5

rr_m <- fetch_datasus(year_start = 2023, month_start = 1,       # microdatasus
                      year_end = 2023, month_end = 1,
                      uf = "RR", information_system = "SIH-RD")
dim(rr_m)
#> [1] 4734  113
rr_mp <- process_sih(rr_m)
dim(rr_mp)
#> [1] 4734  121

Same 4,734 rows, identical sets of N_AIH. healthbR adds year, month and uf_source (the competence and state of the file, which the AIH record does not carry); process_sih() adds eight municipality columns.

The same five records, three ways:

cols <- c("N_AIH", "SEXO", "COD_IDADE", "IDADE", "MORTE", "DT_INTER",
          "DIAG_PRINC", "MUNIC_RES", "VAL_TOT")

as.data.frame(head(rr_h[cols], 5))              # healthbR
#>           N_AIH SEXO COD_IDADE IDADE MORTE   DT_INTER DIAG_PRINC MUNIC_RES  VAL_TOT
#> 1 1423100411392    3         4    18     0 2022-12-13       O809    140017   568.80
#> 2 1423100911661    1         4    83     0 2022-12-26       J189    140010   994.59
#> 3 1423100911672    3         4    65     0 2022-12-03       I219    140010   620.12
#> 4 1423100911683    1         4    58     0 2022-12-26       I248    140020   333.08
#> 5 1423100911694    1         4    35     0 2022-11-01       A499    140010 11202.59

as.data.frame(head(rr_m[cols], 5))              # microdatasus, raw
#>           N_AIH SEXO COD_IDADE IDADE MORTE DT_INTER DIAG_PRINC MUNIC_RES  VAL_TOT
#> 1 1423100411392    3         4    18     0 20221213       O809    140017    568.8
#> 2 1423100911661    1         4    83     0 20221226       J189    140010   994.59
#> 3 1423100911672    3         4    65     0 20221203       I219    140010   620.12
#> 4 1423100911683    1         4    58     0 20221226       I248    140020   333.08
#> 5 1423100911694    1         4    35     0 20221101       A499    140010 11202.59

as.data.frame(head(rr_mp[c(cols, "munResNome")], 5))   # microdatasus, processed
#>           N_AIH      SEXO COD_IDADE IDADE MORTE   DT_INTER DIAG_PRINC MUNIC_RES  VAL_TOT munResNome
#> 1 1423100411392  Feminino      Anos    18   Não 2022-12-13       O809    140017    568.8      Cantá
#> 2 1423100911661 Masculino      Anos    83   Não 2022-12-26       J189    140010   994.59  Boa Vista
#> 3 1423100911672  Feminino      Anos    65   Não 2022-12-03       I219    140010   620.12  Boa Vista
#> 4 1423100911683 Masculino      Anos    58   Não 2022-12-26       I248    140020   333.08  Caracaraí
#> 5 1423100911694 Masculino      Anos    35   Não 2022-11-01       A499    140010 11202.59  Boa Vista

Note DT_INTER: admissions billed in competence 2023-01 happened in November and December 2022. In both packages the year and month select the billing competence, not the admission date; the admissions of a calendar year keep arriving for a few competences after it (measured on the whole mirror: the four following competences close 99.7–99.9% of a year).

The trap, one line each:

sum(rr_h$MORTE == 1)          # healthbR: integer code
#> [1] 113
sum(rr_m$MORTE == "1")        # microdatasus raw: character code
#> [1] 113
sum(rr_mp$MORTE == 1)         # microdatasus processed: the code is gone
#> [1] 0
table(rr_mp$MORTE)
#>  Não  Sim
#> 4621  113

Same 113 deaths; the third line silently returns zero in a script written against codes. I learned it the hard way: an aggregate cube behind my own SIH MCP server sat for months with sex “unknown” on every row and zero deaths, because the build script compared codes after a process_sih(). No test complained — the label is exactly what prevents suspicion.

Timing

Step healthbR microdatasus
Empty cache, mirror (manifest of 11,157 partitions + one Parquet) 11.4 s —
Empty cache, DATASUS FTP .dbc (source = "datasus") 1.1 s 2.7 s
Same call again in the session 0.5 s (local Parquet cache) downloads again (no cache)
Labels (process_sih()) — 0.8 s
In-memory size 4.8 MB 5.4 MB raw, 5.7 MB processed

On a 349 KB file the FTP wins the first read: the mirror’s price of entry is its 10 MB manifest, downloaded once and revalidated by ETag afterwards (sih_status() reads it in 0.4 s from then on). The mirror pays back on repeated reads, on lazy queries over many competences, and in what comes with the data.

A measurement confession: in the first round, healthbR’s “warm” read took 5.5 s — slower than the cold FTP. The cause was the package’s: building the manifest summary (11k partitions) cost 4.6 s and ran on every call. It was fixed before publishing (the summary is now memoised per manifest content) and the numbers above are from the fixed code. Measuring against the neighbour is a good way to find your own slow paths.

What comes with the data: provenance

From the mirror, the tibble carries the source it was served from and one row per DATASUS file behind it — URL, MD5 and size of the .dbc, record count, processing timestamp and pipeline version:

attr(rr_h, "healthbr_source")
#> [1] "r2"
attr(rr_h, "healthbr_provenance") |>
  select(year, month, uf, records, source_hash_md5, source_size_bytes)
#>    year month uf    records source_hash_md5                  source_size_bytes
#> 1  2023     1 RR       4734 12e74d4b059589ceb47e4136e3b2ce5f            349244

st <- sih_status()
nrow(st); attr(st, "last_updated"); range(st$year)
#> [1] 11157
#> [1] "2026-08-18T12:33:31"
#> [1] 1992 2026

microdatasus 3.0.0 has track_source = TRUE, which appends the name of the .dbc each row came from; it records no hash or timestamp, and the FTP offers none. If a derived product must say exactly which files it was built from, this is the difference that matters.

Lazy: a year without downloading it

sih_data(year = 2023, uf = "RR", lazy = TRUE) |>
  filter(MORTE == 1) |>
  count(month) |>
  collect() |>
  arrange(month)
#>    month     n
#>  1     1   113
#>  2     2   120
#>  3     3   175
#>  4     4   166
#>  5     5   118
#>  6     6    78
#>  7     7    97
#>  8     8   144
#>  9     9   105
#> 10    10   104
#> 11    11   128
#> 12    12   102

19.4 s for twelve competences over the mirror, no local copy. With microdatasus the equivalent downloads twelve .dbc files into memory and filters afterwards. For one small state it makes no difference; for the whole country (a year of SIH is about 2 GB of .dbc) it decides whether the query fits on a laptop.

SIM: deaths, Acre 2022

ac_h  <- sim_data(year = 2022, uf = "AC")                      # 1.5 s
ac_m  <- fetch_datasus(2022, year_end = 2022, uf = "AC",
                       information_system = "SIM-DO")          # 2.1 s
ac_mp <- process_sim(ac_m)                                     # 0.7 s
dim(ac_h); dim(ac_m); dim(ac_mp)
#> [1] 4159   90
#> [1] 4159   87
#> [1] 4159  100

Same 4,159 deaths. The interesting column is age: SIM stores it in three characters, unit first (0 minutes, 1 hours, 2 days, 3 months, 4 years, 5 years over 100), then the value. The packages decode it differently:

# six infant deaths, the same rows in both
#>   IDADE healthbR_age_years md_IDADEanos md_IDADEmeses md_IDADEdias md_IDADEhoras
#> 1   021       3.992699e-05         <NA>          <NA>         <NA>          <NA>
#> 2   101       1.140771e-04         <NA>          <NA>         <NA>             1
#> 3   201       2.737851e-03         <NA>          <NA>            1          <NA>
#> 4   302       1.666667e-01         <NA>             2         <NA>          <NA>
#> 5   102       2.281542e-04         <NA>          <NA>         <NA>             2
#> 6   108       9.126169e-04         <NA>          <NA>         <NA>             8

healthbR adds one continuous age_years (21 minutes is 0.00004 years, 2 months is 0.167), so age bands and rates come from one column. microdatasus splits the field into IDADEminutos, IDADEhoras, IDADEdias, IDADEmeses, IDADEanos, each NA outside its unit, closer to how the death certificate reads. For adult mortality they agree to the year; for infant mortality you reach for different columns.

SINASC: births, Acre 2022

ac_n <- sinasc_data(year = 2022, uf = "AC")                    # 3.1 s
class(ac_n$PESO); sum(is.na(ac_n$PESO)); median(ac_n$PESO, na.rm = TRUE)
#> [1] "integer"
#> [1] 94
#> [1] 3230

ac_nm  <- fetch_datasus(2022, year_end = 2022, uf = "AC",
                        information_system = "SINASC")         # 3.3 s
ac_nmp <- process_sinasc(ac_nm)                                # 3.5 s
sum(is.na(ac_nm$PESO)); sum(is.na(ac_nmp$PESO))
#> [1] 94
#> [1] 14483

Same 14,483 births. One thing to know before relying on process_sinasc() in the versions measured here: it returned PESO (birth weight) as NA on every row, while the raw column had 94 missing out of 14,483. It reproduces offline with the package’s own sample:

sum(is.na(microdatasus::sinasc_sample$PESO))
#> [1] 2
sum(is.na(process_sinasc(microdatasus::sinasc_sample)$PESO))
#> [1] 100

The sentinel recode ("0" and "9999" to NA) is followed by an as.numeric(), and with dplyr 1.2.1 the whole column comes back missing. It is the kind of thing a maintainer fixes in an afternoon once reported; check the package’s news and issues for your versions and, meanwhile, take PESO from the raw tibble. healthbR does not rewrite the column: PESO is parsed to integer and the sentinels are left for you to treat.

Where the bytes come from

healthbR microdatasus
SIM, SINASC, SIA, SINAN, CNES DATASUS FTP, .dbc, decompressed locally DATASUS FTP, .dbc, decompressed locally
SIH, SI-PNI healthbr-data mirror: partitioned Parquet on Cloudflare R2, one partition per DATASUS file, byte-identical values, provenance per file; FTP as fallback DATASUS FTP
Local cache partitioned Parquet per module; a second call never touches the network none
Re-issued files sih_status(): MD5 and size per partition, mirror last_updated —
Lazy queries lazy = TRUE, arrow or duckdb, over the cache or the mirror —
Parallel downloads future::plan(multisession) + furrr sequential

Other routes

Not measured here: PySUS, the Python package over the same FTP, and Base dos Dados, treated SIM and SINASC tables on BigQuery with an R client.

Which one

  • Labels in the tibble, a quick table: microdatasus.
  • Joins, comparisons across years, models: healthbR. Codes stay codes, types are parsed, dictionaries are functions, nothing is rewritten under you.
  • SIH at scale, or repeatedly: healthbR. Local Parquet cache, lazy queries over the mirror, provenance you can cite.
  • SIM fetal/infant/maternal extracts, RJ/SP/ER SIH files, SIGTAP: microdatasus, which has them and healthbR (yet) does not.
  • SI-PNI, SISAB, surveys, ANS, ANVISA: healthbR.

They compose: fetch_datasus() and sih_data(parse = FALSE) return the same character columns for the same file, so you can read with one and label with the other:

sih_data(year = 2023, month = 1, uf = "RR", parse = FALSE) |>
  process_sih()

Reproducing

install.packages(c("microdatasus", "arrow"))
# install.packages("pak"); pak::pak("SidneyBissoli/healthbR")   # dev version

sih_clear_cache(); sim_clear_cache(); sinasc_clear_cache()
system.time(sih_data(year = 2023, month = 1, uf = "RR"))
system.time(fetch_datasus(2023, 1, 2023, 1, uf = "RR",
                          information_system = "SIH-RD"))

The same comparison, as a package vignette: healthbR vs microdatasus. healthbR is on CRAN (0.2.0) and GitHub (development version, with the SIH mirror). Thanks to Raphael Saldanha: microdatasus opened the road healthbR travels, and its adoption of healthbR’s .dbc reader in 3.0.0 is the kind of exchange that makes both better.

 

© 2025 Sidney Bissoli · Made with Quarto