# healthbR or microdatasus? The two R packages for DATASUS, side by side on the same download
Sidney Bissoli
2026-09-05

- [Coverage](#coverage)
- [Same reader, two philosophies](#same-reader-two-philosophies)
- [One download in both: SIH, Roraima, competence
  2023-01](#one-download-in-both-sih-roraima-competence-2023-01)
  - [Timing](#timing)
- [What comes with the data:
  provenance](#what-comes-with-the-data-provenance)
- [Lazy: a year without downloading
  it](#lazy-a-year-without-downloading-it)
- [SIM: deaths, Acre 2022](#sim-deaths-acre-2022)
- [SINASC: births, Acre 2022](#sinasc-births-acre-2022)
- [Where the bytes come from](#where-the-bytes-come-from)
- [Other routes](#other-routes)
- [Which one](#which-one)
- [Reproducing](#reproducing)

*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](https://sidneybissoli.github.io/healthbR/articles/healthbr-vs-microdatasus.html);
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](https://CRAN.R-project.org/package=microdatasus), by
Raphael Saldanha, described in Saldanha et al. (2019), is the usual
answer to “how do I get DATASUS into R”.
[healthbR](https://github.com/SidneyBissoli/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.

``` r
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:

``` r
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:

``` r
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:

``` r
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

``` r
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

``` r
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:

``` r
# 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

``` r
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:

``` r
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](https://github.com/SidneyBissoli/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](https://github.com/AlertaDengue/PySUS), the
Python package over the same FTP, and [Base dos
Dados](https://basedosdados.org), 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:

``` r
sih_data(year = 2023, month = 1, uf = "RR", parse = FALSE) |>
  process_sih()
```

## Reproducing

``` r
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](https://sidneybissoli.github.io/healthbR/articles/healthbr-vs-microdatasus.html).
healthbR is on [CRAN](https://CRAN.R-project.org/package=healthbR)
(0.2.0) and [GitHub](https://github.com/SidneyBissoli/healthbR)
(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.
