Skip to content

Let a workbook, a column or a cell say what an empty cell should hold - #143

Merged
billdenney merged 1 commit into
masterfrom
claude/na-values
Aug 3, 2026
Merged

Let a workbook, a column or a cell say what an empty cell should hold#143
billdenney merged 1 commit into
masterfrom
claude/na-values

Conversation

@billdenney

Copy link
Copy Markdown
Collaborator

Closes #76.

writexl writes an empty cell wherever a value has none, which is not always
what a reader wants to see. na names a stand-in:

write_xlsx(df, path, na = "not measured")

Three scopes, innermost wins

Set it on For
xl_properties(na = )write_xlsx(na = ) is the shorthand a whole workbook
xl_col_spec(na = ) one column
xl_cell_general(na = ) one cell

NA at any level means "nothing set here", so a cell falls through to its
column and a column to its workbook. A per-cell na may be a list, and a NULL
element leaves that one cell to inherit.

Columns are usually the right place: a substitute that suits a numeric column
rarely suits a date one.

The substitute keeps its own type

na = 0 writes a number and leaves a numeric column numeric. na = "none"
makes that column mixed, which a reader such as readxl then returns as
character — the documented cost of asking for a non-numeric value in a numeric
column.

That falls out of routing the value through write_atomic_value(), the same
dispatcher an ordinary cell value goes through. Every type writexl can write,
na can write, and there is no second type table to keep in step.

NaN is covered alongside NA, having no representation either. Inf is not,
since it already writes as "Inf".

The default is not a behaviour change, and the test proves it

An unset na reaches C as R_NilValue, write_na() declines it, and the same
worksheet_write_blank() call runs as before.

Rather than assert that, a test compares whole workbooks byte for byte with
created pinned, so the only thing that could differ is the change under test
rather than the timestamp libxlsxwriter stamps on each run. This is the whole
compatibility story for a package whose reverse dependencies all write data
frames with NAs in them, so it is worth pinning at that strength.

A cell with content is untouched

The substitution sits at the point where write_atomic_value() reports it had
nothing to write — which is already after the formula, hyperlink and
rich-string branches. So a formula cell keeps its formula, and only a cell with
no content at all takes the stand-in. A formatted blank keeps its format, as it
did before.

The header row is never substituted: it is always written, so it has no missing
value to stand in for. A test pins that too.

Three decisions made while implementing

  1. write_xlsx(na = ) errors when x is already an xl_workbook, pointing
    at xl_properties(na = ). The workbook carries its own properties, so the
    shorthand would otherwise be silently ignored — the same trap col_names
    already has, but this one is new so it can fail loudly instead.
  2. A length-2 na on a one-cell column was silently truncating. Caught by a
    test written for something else; it now errors naming the length it wanted.
  3. xl_cell_general()'s documented promise changed. "Use value = NA for
    an explicit empty cell"
    stops being true once a workbook na is set, so the
    wording is updated and na is documented as how a cell asks for its blank
    back.

Known limitation

There is no spelling for "this column writes a blank even though the workbook
substitutes something"
. NA means inherit at the inner scopes, so it cannot
also mean override-to-blank. na = "" gets an empty string, which is close but
not the same cell. Easy to add later if anyone wants it.

Verification

  • R CMD check: 0 errors, 0 warnings, 0 notes
  • lintr::lint_package(): 0
  • 100% of every R line covered; src/write_xlsx.c at 99.26%, the same
    defensive lines as before
  • Spelling clean
  • src/libxlsxwriter/ untouched

🤖 Generated with Claude Code

writexl writes an empty cell wherever a value has none, which is not always
what a reader wants to see (#76).  `na` names a stand-in:

  write_xlsx(df, path, na = "not measured")

It is set at three scopes -- xl_properties(na =) for a workbook, with
write_xlsx(na =) as the shorthand; xl_col_spec(na =) for a column, which is
usually the right place, since a substitute that suits a numeric column rarely
suits a date one; and xl_cell_general(na =) for a single cell.  The innermost
one that is set wins, and NA at any level means "nothing set here", so a cell
falls through to its column and a column to its workbook.

The substitute keeps its own type.  `na = 0` writes a number and leaves a
numeric column numeric; `na = "none"` makes that column mixed, which a reader
such as readxl then returns as character.  That falls out of routing the value
through write_atomic_value(), the same dispatcher an ordinary cell value goes
through -- so every type writexl can write, `na` can write, with no second type
table to keep in step.  NaN is covered alongside NA, having no representation
either; Inf is not, since it already writes as "Inf".

The default is `na = NA`, and it is not a behaviour change: an unset `na`
reaches C as R_NilValue, write_na() declines it, and the same worksheet_write_
blank() call runs as before.  A test asserts that byte for byte over a whole
workbook rather than by inspection -- `created` is pinned so the only thing
that could differ is the change under test.

A cell with content is untouched: the substitution sits at the point where
write_atomic_value() reports it had nothing to write, which is already after
the formula, hyperlink and rich-string branches.  So a formula cell keeps its
formula, and only a cell with no content at all takes the stand-in.
@billdenney
billdenney merged commit b81400b into master Aug 3, 2026
10 of 12 checks passed
@billdenney
billdenney deleted the claude/na-values branch August 3, 2026 16:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Set default value for NAs

1 participant