week county disease cases rate
1 1 Fairfax Flu 23 2.1
2 2 Arlington Flu 41 3.8
3 3 <NA> Flu 18 1.6
4 4 Loudoun Flu -5 NA
5 4 Loudoun Flu 12 1.1
3 Data Validation and QA
An analysis can run to completion on bad data. Negative case counts, duplicate records, and unexpected column types may affect tables and figures without stopping the code. Check for these problems before using the data.
The remedy is to validate data at the moment of ingestion, before any analysis runs. If the data does not meet your expectations, the code should fail immediately and loudly, with a message that explains why. This chapter covers two tools for doing that: base R assertions for simple, targeted checks, and the pointblank package for structured validation of an entire dataset.
Throughout this chapter, the examples use the following simulated weekly influenza surveillance dataset. It has four deliberate problems: a negative case count, a missing county, a missing rate, and a duplicate record.
3.1 Failing Fast
The simplest validation is a condition check paired with stop(). If the condition fails, execution halts and the message you wrote appears in the console. With error: true, Quarto also includes the error in the rendered output.
Error:
! Negative case counts detected. Inspect raw data before proceeding.
The error: true chunk option lets the document continue rendering even after an error, as needed for this demonstration. In a real analysis, you would not set this option; you would want rendering to stop until the data problem is fixed.
For multiple checks, stopifnot() is more concise. Named arguments become the error message, so the output tells you which check failed:
stopifnot(
"Negative case counts" = all(flu$cases >= 0, na.rm = TRUE),
"Missing county values" = !anyNA(flu$county),
"Duplicate records" = !anyDuplicated(flu[, c("week", "county")])
)Error:
! Negative case counts
The error stops at the first failure. If cases and county both have problems, you only learn about cases until you fix it and re-run. For a dataset with many potential issues, that feedback loop is slow.
3.2 Structured Validation with pointblank
pointblank replaces the one-at-a-time assertion model with a declarative pipeline: you describe every expectation up front, run them all at once, and get a structured report showing which passed and which failed, without stopping at the first problem.
install.packages("pointblank")3.2.1 Basic Workflow
The core workflow has three steps: create an agent bound to the data, add validation steps, then interrogate. The checks below detect the negative count, missing county, and duplicate week/county key in flu.
The pointblank outputs are only rendered in html format at https://dstt.stephenturner.us/. These interactive formats are not rendered in the PDF or EPUB versions of this guide.
library(pointblank)
agent <- create_agent(tbl = flu, label = "Weekly flu surveillance") |>
col_vals_gte(
columns = cases,
value = 0,
label = "Case counts must be non-negative"
) |>
col_vals_not_null(
columns = c(week, county),
label = "Week and county cannot be missing"
) |>
rows_distinct(
columns = c(week, county),
label = "No duplicate week/county records"
) |>
interrogate()
agentAll three checks fail: row 4 has cases = -5, row 3 has county = NA, and rows 4 and 5 are both week = 4, county = "Loudoun". Each failure row shows the count of offending records. All three checks ran, so you can investigate the failures together.
3.2.2 Adding More Checks
Real surveillance data has more constraints. A few more validations on the same dataset:
create_agent(tbl = flu, label = "Weekly flu surveillance: extended") |>
col_is_numeric(
columns = c(cases, rate),
label = "Case count and rate must be numeric"
) |>
col_vals_in_set(
columns = disease,
set = c("Flu", "COVID-19", "RSV"),
label = "Disease must be from the approved list"
) |>
col_vals_between(
columns = week,
left = 1,
right = 53,
label = "Week must be between 1 and 53"
) |>
col_vals_gte(
columns = rate,
value = 0,
na_pass = TRUE,
label = "Rate must be non-negative (NAs allowed)"
) |>
interrogate()| Pointblank Validation | |||||||||||||
|
Weekly flu surveillance: extended
data frame flu
|
|||||||||||||
| STEP | COLUMNS | VALUES | TBL | EVAL | UNITS | PASS | FAIL | W | S | N | EXT | ||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | Case count and rate must be numeric |
|
— | ✓ | 1 |
11
|
00
|
— | — | — | — | ||
| 2 | Case count and rate must be numeric |
|
— | ✓ | 1 |
11
|
00
|
— | — | — | — | ||
| 3 | Disease must be from the approved list |
|
|
✓ | 5 |
51
|
00
|
— | — | — | — | ||
| 4 | Week must be between 1 and 53 |
|
|
✓ | 5 |
51
|
00
|
— | — | — | — | ||
| 5 | Rate must be non-negative (NAs allowed) |
|
|
✓ | 5 |
51
|
00
|
— | — | — | — | ||
| 2026-09-20 16:49:14 EDT < 1 s 2026-09-20 16:49:14 EDT | |||||||||||||
The na_pass = TRUE argument on the rate check lets missing values pass that particular step. For this rate variable, we allow missing values but reject negative ones. These two conditions are separate checks and should be stated separately.
3.2.3 Common Checks for Public Health Data
| Validation | pointblank function | Use for |
|---|---|---|
| No negative counts | col_vals_gte(value = 0) |
Case counts, deaths, population denominators |
| No missing required fields | col_vals_not_null() |
County, date, disease code |
| No duplicate records | rows_distinct() |
Surveillance records keyed by geography + time |
| Values from a known list | col_vals_in_set() |
Disease codes, county names, FIPS codes |
| Dates within a valid range | col_vals_between() |
Report dates, surveillance weeks |
| Correct column type |
col_is_numeric(), col_is_date()
|
Catches silent type coercion on import |
| Counts do not exceed a known total | col_vals_lte() |
Deaths ≤ total cases, OD deaths ≤ total deaths |
3.2.4 Stopping on Failure
By default, interrogate() returns the full report without stopping execution. To stop an automated pipeline when validation fails, check the result with all_passed():
if (!all_passed(agent)) {
stop("Data validation failed. Review the agent report before proceeding.")
}Error:
! Data validation failed. Review the agent report before proceeding.
This gives you the structured report from pointblank for diagnosis while still failing loudly enough to stop an automated run.
3.3 Where to Put Validation
Validation belongs in the setup block, immediately after data is read in and before any transformation or analysis begins. Add further checks after transformations that could introduce errors, such as joins and aggregations.
library(readr)
library(pointblank)
flu <- read_csv("data/flu-2024.csv")
# Validate immediately after reading
agent <- create_agent(tbl = flu, label = "flu-2024 validation") |>
col_vals_gte(columns = cases, value = 0, label = "No negative counts") |>
col_vals_not_null(columns = c(week, county), label = "No missing keys") |>
rows_distinct(columns = c(week, county), label = "No duplicate records") |>
interrogate()
if (!all_passed(agent)) {
stop("Validation failed: see agent report above.")
}
# Analysis only runs if validation passesPutting validation here means the failure is traceable to a specific data file and a specific expectation. The message tells you what was wrong, not just that something broke three steps later in an aes() call.
Commit your validation code to version control alongside the analysis (see Chapter 1). When a data supplier changes a format, adds a new disease code, or starts providing a column in a different type, your validation catches it on the next render, if you have checks for those changes. Data pulled from a web API deserves the same treatment, for reasons covered in Section 18.7. When validation failures trace back to a change in an upstream data system, Chapter 25 covers how to work with IT and data stewards to resolve it.