Skip to content

Match external write option names case-insensitively - #869

Merged
jwills merged 2 commits into
duckdb:masterfrom
DG47:fix/case-insensitive-external-options
Oct 28, 2026
Merged

jwills merged 2 commits into
duckdb:masterfrom
DG47:fix/case-insensitive-external-options

Conversation

@DG47

@DG47 DG47 commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator

Fixes #232

DuckDB accepts COPY ... TO options in any case, but the external materialization only recognized lower-case option names. In #232 a model with options={"PARTITION_BY": "date"} wrote the partitioned files and then failed creating the __dbt_int view, because external_read_location did not see the partition option and read the directory instead of the files below it. The workaround in that thread was to switch to lower-case partition_by.

On current master the same problem shows up in a few places:

options before after
{"PARTITION_BY": "id"} view reads <location> instead of <location>/*/*.parquet reads the partition files
{"PARTITION_BY": "id, name"} value not wrapped in parentheses: Unrecognized option "name" for parquet partition_by (id, name)
{"FORMAT": "csv"} a second format parquet is appended: Unexpected duplicate option "format" format csv
{"PARTITION_BY": ...} with no location default location gets a .parquet suffix directory without extension, as for partition_by

Changes:

  • external_write_options lower-cases the option names in place before inspecting them, so the materialization's later reads of rendered_options['format'] and rendered_options['compression'] see them too. The quoting check already compared k.lower(); it now compares the normalized key.
  • external_read_location looks the options up case-insensitively (without mutating the caller's dict). This also covers register_upstream_external_models.
  • external_location finds partition_by in any case when choosing the default location.

Tests:

  • tests/unit/test_external_utils.py: upper/mixed-case cases for external_write_options and external_read_location, plus a check that the dict is normalized in place.
  • tests/functional/adapter/test_write_options.py: two models using PARTITION_BY/CODEC and FORMAT/DELIMITER, checked against the seed with check_relations_equal, and a check that partitioned outputs default to a directory without an extension. Both fail on master and pass with this change.
pytest tests/unit                      -> 195 passed
pytest -n 8 tests/functional/adapter   -> 194 passed, 16 skipped
pre-commit run --files <changed files> -> all hooks passed

DuckDB accepts COPY options in any case, but the external materialization
only recognized lower-case names. An options key such as PARTITION_BY was
passed through unchanged, so a multi-column value was not wrapped in
parentheses, the read-back view pointed at the partition directory instead
of the files below it, and the default location got a file extension. An
upper-case FORMAT was followed by a second, inferred format option and
DuckDB rejected the duplicate.

Lower-case the option names before the adapter inspects them, and look up
partition_by case-insensitively when picking the default location.
@jwills

jwills commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator

thanks, this looks great! one thing: when two keys differ only by case they now get silently merged. something like config(format='csv', options={'FORMAT': 'parquet'}) used to fail with DuckDB's duplicate-option error, and now csv just wins because render_write_options adds the top-level format last.

how do you think we should handle that? my lean is to raise a compiler error when two keys normalize to the same name (plus a unit test for it), but I'm open to a defined precedence if you think that's better.

@DG47

DG47 commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Agreed, an error is better than a precedence rule. A config that sets the same option twice is almost always a mistake, and erroring keeps the old failure instead of silently picking one value. Done in d90e289: external_write_options now raises a CompilationError that names both keys when two of them normalize to the same option, and there's a unit test for the FORMAT / format case.

With your example (config(materialized='external', format='csv', options={'FORMAT': 'parquet'})) the run now fails with:

Compilation Error in model m (models/m.sql)
  The write options 'FORMAT' and 'format' set the same DuckDB option, which is case-insensitive; set it only once

Unit tests (196) and test_write_options.py pass, and pre-commit is clean.

@jwills jwills left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks for the quick turnaround on this!

@jwills
jwills merged commit 643aade into duckdb:master Oct 28, 2026
38 checks passed
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.

Intermediate Relation Fails with External Materialization with Partitions

2 participants