Loading a structured data file

Open a CSV file and check what R received

source data loading
CSV
data quality
Learn how rows, columns, missing values, and text encodings affect a CSV file in R.

Before a community team’s weekly meeting, a coordinator receives one CSV file containing feedback from email, web forms, and live chat. The group plans to review low ratings. First, the coordinator has to find out what the five records contain and whether their structure survived the move into R.

A file can open without an error while a column is missing, an accent mark is damaged, or an identification number has changed format. A blank rating can also be mistaken for a score if its meaning is not checked. We will follow the coordinator’s arrival check from the file’s raw lines to the one low rating the table can support, then repeat the check on a second export that arrives with a repeated identifier, an impossible rating, and a row that has too many fields.

Note

The feedback records in this lesson are fictional and were created for teaching. They do not describe real people or customers.

TipWhat you will learn

By the end of this lesson, you will be able to:

  • explain how a structured data file organizes information;
  • load a CSV file into R;
  • check rows, columns, and data types;
  • find missing values;
  • inspect the parse problems, duplicate keys, and out-of-range values in a flawed file; and
  • select records that meet a stated condition.

What makes a file structured?

The first uncertainty is basic: what does each part of the file represent? A structured data file follows a consistent arrangement. In a CSV file:

  • each row usually represents one record;
  • each column represents one kind of information; and
  • commas separate one column from the next.

CSV stands for comma-separated values. The first line often contains the column names. Text containing a comma is placed inside quotation marks so that the comma remains part of the text. A quoted field can also contain a line break, so one physical line does not always equal one record. A quotation mark inside a quoted field is usually written twice.

Before asking R to make a table, the coordinator reads the file as plain lines. read_lines() from readr returns one text value for each physical line. This exposes what is actually stored and keeps the example small enough to count by eye.

library(readr)
library(dplyr)
library(tibble)
library(purrr)
library(stringr)

file_path <- "data/customer_feedback.csv"
file_lines <- read_lines(file_path)

head(file_lines, 3)
[1] "feedback_id,submitted_at,channel,rating,comment"             
[2] "1001,2026-08-21,email,5,\"Delivery was quick, thank you!\""  
[3] "1002,2026-08-22,web,4,\"The café listing was easy to find.\""

The file has six lines: one header and five records. The first comment contains a comma, and its quotation marks keep the full sentence in one field rather than splitting it across columns. This line count works because the teaching file has no quoted line breaks.

Load the CSV into R

The line count and header are as expected, so the next step is to organize the records. read_csv() from readr turns the file into a tibble, a table that prints its size and each column’s type. readr treats text as UTF-8 by default, a common way to store characters from many languages, so the accented text is read using that expected encoding.

feedback <- read_csv(
  file = file_path,
  na = "",
  col_types = cols(
    feedback_id = col_character(),
    submitted_at = col_character(),
    channel = col_character(),
    rating = col_integer(),
    comment = col_character()
  )
)

knitr::kable(
  feedback,
  col.names = c("Feedback ID", "Submitted", "Channel", "Rating", "Comment"),
  caption = "Customer feedback loaded from the CSV file",
  row.names = FALSE
)
Customer feedback loaded from the CSV file
Feedback ID Submitted Channel Rating Comment
1001 2026-08-21 email 5 Delivery was quick, thank you!
1002 2026-08-22 web 4 The café listing was easy to find.
1003 2026-08-23 chat NA I could not reset my password.
1004 2026-08-24 email 2 The parcel arrived late.
1005 2026-08-25 web 5 Muy útil y fácil de usar.

All five records appear in five columns. The comma in the first comment stayed inside the comment, and the accented letters in café display correctly. The final comment, "Muy útil y fácil de usar", is Spanish for “Very useful and easy to use.”

The teaching file’s data dictionary defines a blank rating as missing. na = "" applies that rule while loading. A real project should use the missing-value rules supplied by its data provider. The literal text "NA", zero, and a blank field must not be treated as equivalent without such evidence.

Those checks show that the visible layout survived the trip into R. We still need to check how R interpreted each column.

Why choose the column types?

The col_types = cols(...) setting tells readr which data type to use for each column. A data type records whether values should behave as text, whole numbers, dates, or another kind of information.

  • identification numbers are text because they label records;
  • dates begin as text so we can convert them deliberately;
  • ratings are whole numbers; and
  • channels and comments are text.

Treating an ID as text protects leading zeros. For example, the label "0012" should remain different from "12" when that difference exists in the source.

We can ask R to report the type it used for every column. map_chr() from purrr applies the same check to each column and returns a character vector. The shorthand \(column) means “for each column, run the small calculation that follows.”

column_types <- map_chr(
  feedback,
  \(column) class(column)[1]
)

knitr::kable(
  tibble(
    column = names(column_types),
    type = unname(column_types)
  ),
  col.names = c("Column", "Type used by R"),
  caption = "The type assigned to each column",
  row.names = FALSE
)
The type assigned to each column
Column Type used by R
feedback_id character
submitted_at character
channel character
rating integer
comment character

R reports character for text and integer for whole numbers. Here, character is R’s name for text; a value may contain an entire comment. Choosing these types deliberately prevents R from turning record labels into quantities.

Convert the date column

The submission dates have arrived as text. mutate() from dplyr changes a column while keeping the rest of the table. Here as.Date() converts the text to R’s date type so the coordinator can sort and compare the values as calendar dates.

feedback <- feedback |>
  mutate(
    submitted_at = as.Date(
      submitted_at,
      format = "%Y-%m-%d"
    )
  )

knitr::kable(
  feedback |>
    select(feedback_id, submitted_at, channel),
  col.names = c("Feedback ID", "Submitted", "Channel"),
  caption = "Submission dates after conversion",
  row.names = FALSE
)
Submission dates after conversion
Feedback ID Submitted Channel
1001 2026-08-21 email
1002 2026-08-22 web
1003 2026-08-23 chat
1004 2026-08-24 email
1005 2026-08-25 web

The format %Y-%m-%d means four-digit year, two-digit month, and two-digit day. Writing it explicitly records the order expected in this file rather than leaving that order to a guess.

Find missing information

With the rows, columns, and types accounted for, the coordinator can ask which values are absent. R uses NA to represent a missing value. The CSV contains one blank rating, which read_csv() changed to NA. map_int() returns one whole-number count for each column.

missing_counts <- map_int(
  feedback,
  \(column) sum(is.na(column))
)

knitr::kable(
  tibble(
    column = names(missing_counts),
    missing_values = unname(missing_counts)
  ),
  col.names = c("Column", "Missing values"),
  caption = "Missing values in each column",
  row.names = FALSE
)
Missing values in each column
Column Missing values
feedback_id 0
submitted_at 0
channel 0
rating 1
comment 0

The rating column has one missing value. A blank tells us only that no rating is recorded in this file. It is different from a rating of zero and should not be replaced without evidence about what the blank means.

Select a record that needs attention

Only now does the coordinator select feedback with a rating of two or lower. filter() from dplyr keeps rows that meet a condition, and select() keeps the columns needed for the meeting. The expression !is.na(rating) keeps the unknown rating out of the comparison rather than treating it as high or low.

low_ratings <- feedback |>
  filter(!is.na(rating), rating <= 2) |>
  select(feedback_id, rating, comment)

knitr::kable(
  low_ratings,
  col.names = c("Feedback ID", "Rating", "Comment"),
  caption = "Feedback with a rating of two or lower",
  row.names = FALSE
)
Feedback with a rating of two or lower
Feedback ID Rating Comment
1004 2 The parcel arrived late.

One record meets the condition: feedback 1004, which says that the parcel arrived late. The missing rating remains separate because the file does not tell us how that person would have scored the experience.

When the next file is not clean

Everything above worked because the file was well formed. That is the uninteresting case. The next export from the same web form is written out below, inside the lesson rather than saved to disk, so the damage is visible line by line.

It contains six records and six problems: a repeated feedback_id, a rating typed as a word, a row with an extra field, a rating outside the documented range of one to five, a blank rating, and a comment that runs across two physical lines inside its quotation marks.

messy_text <- paste(
  "feedback_id,submitted_at,channel,rating,comment",
  "1006,2026-08-26,email,4,\"Arrived on time\"",
  "1006,2026-08-26,email,4,\"Second row with the same ID\"",
  "1007,2026-08-27,chat,five,\"Rating typed as a word\"",
  "1008,2026-08-27,web,3,\"Extra field\",unexpected",
  "1009,2026-08-28,web,9,\"Rating outside the documented range\"",
  "1010,2026-08-28,email,,\"Comment that runs\non to a second line\"",
  sep = "\n"
)

messy_lines <- read_lines(I(messy_text))
messy_feedback <- suppressWarnings(
  read_csv(
    I(messy_text),
    na = "",
    col_types = cols(
      feedback_id = col_character(),
      submitted_at = col_character(),
      channel = col_character(),
      rating = col_integer(),
      comment = col_character()
    )
  )
)
messy_raw <- suppressWarnings(
  read_csv(
    I(messy_text),
    na = character(),
    col_types = cols(
      feedback_id = col_character(),
      submitted_at = col_character(),
      channel = col_character(),
      rating = col_character(),
      comment = col_character()
    )
  )
)

tibble(
  measure = c("Physical lines", "Records read"),
  count = c(length(messy_lines), nrow(messy_feedback))
)
# A tibble: 2 × 2
  measure        count
  <chr>          <int>
1 Physical lines     8
2 Records read       6

Eight lines produced six records. The quoted line break is the reason, and it is why the arrival check earlier in this lesson could only compare a line count with a record count on a file known to have none.

suppressWarnings() appears because this page turns warnings into errors. Suppressing the warning does not discard the evidence: readr keeps a structured report of everything it could not parse.

Read the parse problems

parse_problems <- problems(messy_feedback) |>
  transmute(
    row,
    record_number = row - 1L,
    feedback_id = messy_raw$feedback_id[record_number],
    col,
    expected,
    actual
  )

knitr::kable(
  parse_problems,
  col.names = c(
    "File row",
    "Record",
    "Feedback ID",
    "Column",
    "Expected",
    "Found"
  ),
  caption = "What readr could not parse",
  row.names = FALSE
)
What readr could not parse
File row Record Feedback ID Column Expected Found
4 3 1007 4 an integer five
5 4 1008 6 5 columns 6 columns

readr counts the header line, so the reported row 4 is the third record, the one whose rating was typed as five. That value became NA. The row with an extra field kept its data by folding the stray text into the comment, which is worth knowing: a parse problem does not always produce a missing value, and a missing value does not always come from a parse problem.

The transferable idea is not the function name. Declaring column types and missing-value rules at read time, and keeping the parser’s complaint, is the same discipline that dtype and na_values serve in pandas or an explicit schema serves in Arrow. A parser that guesses without reporting the guess is the hazard in every language.

Check the things a parser cannot know

Parsing succeeds when the shape is right. It says nothing about whether the identification numbers are unique or the ratings are possible. Those rules come from the data dictionary, and each one can be written as a check that fails loudly.

documented_rating_range <- 1:5
parse_failure_ids <- parse_problems |>
  filter(expected == "an integer") |>
  pull(feedback_id)
extra_field_ids <- parse_problems |>
  filter(expected == "5 columns") |>
  pull(feedback_id)

row_report <- messy_feedback |>
  mutate(
    record_number = row_number(),
    raw_rating = messy_raw$rating,
    duplicate_id = feedback_id %in%
      feedback_id[duplicated(feedback_id)],
    parse_failed = feedback_id %in% parse_failure_ids,
    declared_missing = raw_rating == "",
    rating_out_of_range = !is.na(rating) &
      !(rating %in% documented_rating_range),
    extra_field = feedback_id %in% extra_field_ids,
    status = case_when(
      duplicate_id ~ "quarantine: repeated feedback_id",
      parse_failed ~ "quarantine: rating failed integer parsing",
      rating_out_of_range ~ "quarantine: rating outside 1 to 5",
      extra_field ~ "quarantine: unexpected sixth field",
      declared_missing ~ "keep: rating recorded as missing",
      TRUE ~ "keep"
    )
  )

status_counts <- row_report |>
  count(status, name = "records")

quarantined <- row_report |>
  filter(str_starts(status, "quarantine"))

knitr::kable(
  row_report |>
    select(record_number, feedback_id, raw_rating, rating, status),
  col.names = c(
    "Record",
    "Feedback ID",
    "Raw rating",
    "Parsed rating",
    "Decision"
  ),
  caption = "Every record accounted for, including the ones held back",
  row.names = FALSE
)
Every record accounted for, including the ones held back
Record Feedback ID Raw rating Parsed rating Decision
1 1006 4 4 quarantine: repeated feedback_id
2 1006 4 4 quarantine: repeated feedback_id
3 1007 five NA quarantine: rating failed integer parsing
4 1008 3 3 quarantine: unexpected sixth field
5 1009 9 9 quarantine: rating outside 1 to 5
6 1010 NA keep: rating recorded as missing

Five of six records are held back and the reasons differ. Two share an ID, so neither can be trusted as the record for 1006 until the source explains the repeat. The raw value five failed integer parsing and is not treated as an ordinary missing rating. One row has an unexpected sixth field. One carries a rating of nine, which the dictionary does not allow.

The remaining record has a blank rating. A blank that the dictionary defines as missing is not a parse error.

The important discipline is the accounting: six records in, six records classified, nothing dropped in silence. A pipeline that filtered to the clean rows and moved on would have reported one tidy record and lost the fact that a duplicate ID exists in the source.

Questions to ask about any structured file

The five-row arrival check is complete, but technical tidiness cannot answer every question about the feedback. Before analyzing any structured file, ask:

  1. What does one row represent?
  2. What does each column mean?
  3. Which encoding does the file use?
  4. How are missing values recorded?
  5. Are dates and identification numbers stored correctly?
  6. Who created the file, and what information did they leave out?

A tidy table cannot tell you whether the collection process was fair or complete. These rows represent only the feedback that reached the recorded channels and was included in this file. The file’s origin, exclusions, and limits belong alongside the technical checks.

What to remember

  • A structured file uses a consistent arrangement of rows and columns.
  • Quotation marks protect text that contains a separator such as a comma.
  • Quoted line breaks make physical lines and records different counts.
  • Choose important column types rather than relying on guesses.
  • Read the parser’s problem report instead of only its output table.
  • Uniqueness and value ranges come from the data dictionary, not the parser.
  • Check missing values before filtering or summarizing.
  • Opening a file successfully is only the beginning of checking it.

At the meeting, the coordinator can report five records, one missing rating, and one recorded rating of two. The second file is a different report: six records, one usable, five held back with a stated reason each. The blank remains a blank, and the file’s reach remains an open question. That is the useful discipline of an arrival check: say exactly what is recorded, what is blank, and what the file cannot settle.

Sources