Azure Data Engineering Notes · Part III — Synapse Serverless SQL Pools Chapter 7 / 9
Chapter Seven

Querying CSV Files with OPENROWSET

How to read a delimited file straight out of a data lake, type it correctly, and avoid the handful of mistakes that account for nearly every error you'll hit along the way.

Nothing here is copied or staged. Run the same query twice and the file is read twice.

7.1

What "serverless" actually means

A serverless SQL pool is a query engine with no database attached to it. It doesn't hold your data — it reaches out to wherever your data already lives, reads it, and hands back a result.

That single fact explains almost everything about how this chapter's syntax is shaped: why every query needs a schema spelled out by hand, why nothing you run ever "inserts" anywhere, and why the billing model looks nothing like a traditional database.

No ingestion

Nothing is loaded into the pool. Every query reads the source file directly from storage at execution time.

Billed by data scanned

Cost is a function of how much data a query reads, not reserved compute sitting idle around the clock.

Always current

There's no sync step and no staleness window. A query reflects exactly what's in the file right now.

Compute on demand

Resources are provisioned per query and released afterward, rather than an always-on cluster waiting for work.

Serverless vs. dedicated, at a glance

 Serverless SQL poolDedicated SQL pool
Where data livesStays in the data lakePhysically loaded into the pool
ComputeSpun up per queryAlways-on, provisioned capacity
BillingPer TB of data processedPer hour of reserved compute
Typical useAd hoc exploration, data lake queries, external tablesLarge-scale, high-concurrency production warehousing
Postgres bridge

The closest mental model from the Postgres world is a foreign data wrapper — CREATE SERVER plus a foreign table. You're registering a connection to external data and querying it live, not importing it. CREATE EXTERNAL DATA SOURCE, covered later in this chapter, is functionally the same idea as CREATE SERVER.

7.2

Anatomy of OPENROWSET

OPENROWSET is the function that does the actual reading. Everything else in this chapter is really just options passed into it.

a minimal, complete exampleT-SQL
SELECT TOP 100 *
FROM OPENROWSET(
    BULK 'raw/zone_lookup.csv',
    DATA_SOURCE = 'nyc_taxi_data',
    FORMAT = 'CSV',
    PARSER_VERSION = '2.0',
    HEADER_ROW = TRUE
)
WITH (
    LocationID    SMALLINT,
    Borough       VARCHAR(15),
    Zone          VARCHAR(50),
    service_zone  VARCHAR(15)
) AS [r];
BULK

The path to the file. Either a full abfss:// URI, or — as here — a path relative to a registered DATA_SOURCE. This is the only truly mandatory argument.

DATA_SOURCE

Optional. Resolves the relative BULK path against a named pointer created ahead of time with CREATE EXTERNAL DATA SOURCE. Covered in full in section 7.8.

FORMAT

Which parser to invoke: CSV, PARQUET, or DELTA. There is no dedicated TSV, pipe-delimited, or fixed-width format — those are all still CSV with different delimiter options, covered in section 7.7.

PARSER_VERSION

1.0 is the older engine and cannot infer schema at all. 2.0 is faster and the current default recommendation, but for CSV it still generally needs an explicit schema — see section 7.3.

WITH ( ... )

The explicit schema: column names, types, and — for headerless files — ordinal positions. Required for CSV in almost every real scenario.

AS [r]

A mandatory alias. OPENROWSET returns a derived table, and T-SQL requires every derived table to be named before its columns can be referenced — the same rule Postgres applies to subqueries in a FROM clause.

Postgres bridge

The square brackets around [r] are T-SQL's identifier-quoting syntax — the equivalent of double quotes around an identifier in Postgres. They're only strictly necessary around reserved words or names with special characters; AS r without brackets works identically here.

7.3

Why CSV can't describe itself

A CSV file is plain text. It has no embedded metadata — no column names guaranteed, no types, nothing a parser can inspect ahead of time the way it can with a self-describing format like Parquet.

Leave out the WITH clause on a CSV read, and this is what you get back:

Started executing query
Schema cannot be determined from data files for file format 'CSV 1.0'. Please use WITH clause of OPENROWSET to define schema.

The fix is always the same: add a WITH clause naming every column and its type. This is not optional cleanup — for CSV, it's the normal, expected way to write this query.

Sizing string columns deliberately

Guessing a VARCHAR length and getting it wrong doesn't fail quietly — the engine refuses to silently truncate data:

Started executing query
String or binary data would be truncated while reading column of type 'VARCHAR'. Check ANSI_WARNINGS option. Underlying data description: file '<path>', column 'Zone'. Truncated value: '"Allerton/Pelham Gardens"'.

The right habit is to check the real maximum length before committing to a size, rather than guess and re-run:

checking real column widths firstT-SQL
SELECT
    MAX(LEN(LocationID))   AS len_location_id,
    MAX(LEN(Borough))      AS len_borough,
    MAX(LEN(Zone))         AS len_zone,
    MAX(LEN(service_zone)) AS len_service_zone
FROM OPENROWSET(
    BULK 'raw/zone_lookup.csv',
    DATA_SOURCE = 'nyc_taxi_data',
    FORMAT = 'CSV',
    PARSER_VERSION = '2.0',
    HEADER_ROW = TRUE
) AS [r];

Run that once, read the widest value per column, then size VARCHAR to comfortably cover it — with a little headroom for future rows you haven't seen yet.

Validating a schema without a full scan

There's a second tool worth knowing for this: sp_describe_first_result_set. It takes a query as a string and returns the schema that query would produce, without actually running it against the data.

dry-run schema validationT-SQL
EXEC sp_describe_first_result_set N'
SELECT *
FROM OPENROWSET(
    BULK ''raw/zone_lookup.csv'',
    DATA_SOURCE = ''nyc_taxi_data'',
    FORMAT = ''CSV'',
    PARSER_VERSION = ''2.0'',
    HEADER_ROW = TRUE
)
WITH (
    LocationID SMALLINT,
    Borough    VARCHAR(15),
    zone       VARCHAR(15),
    service_zone VARCHAR(15)
) AS [r]';

Two things are worth noticing in that call. The leading N before the string marks it as Unicode text (NVARCHAR) rather than plain VARCHAR — the procedure's parameter specifically expects Unicode, so the prefix isn't optional style, it's what the signature requires. And every single quote that belongs to the inner query is doubled ('') rather than single, because the whole statement is itself one big string literal — a single unescaped quote would end that literal early.

Postgres bridge

Postgres doesn't have a direct one-shot equivalent to sp_describe_first_result_set. The closest habits are EXPLAIN or querying information_schema — useful, but neither hands you a column list for an arbitrary ad hoc query the way this does.

7.4

VARCHAR vs. NVARCHAR

Both store text. The difference is which characters they can safely hold, and how much storage each character costs.

 VARCHARNVARCHAR
Character supportLimited to the column's collationFull Unicode
Storage per character1 byte2 bytes
Typical useCodes, IDs, plain Latin/English textFree text, names, anything of uncertain origin

A field like service_zone, drawn from a small fixed set of English labels, is a safe fit for VARCHAR. Raw JSON text, user-entered free text, or anything that might carry a name in a non-Latin script belongs in NVARCHAR — and when the length is genuinely unpredictable, NVARCHAR(MAX) removes the guessing entirely.

Postgres bridge

This split doesn't really exist in Postgres. text and varchar there are Unicode-aware by default, given a UTF-8 database encoding — which is nearly universal today. The VARCHAR / NVARCHAR distinction is a T-SQL artifact from an era before UTF-8 was assumed everywhere.

7.5

Collation and character encoding

Collation is the rule set that governs how text is compared and sorted — case sensitivity, accent sensitivity, and character encoding all live inside one named setting.

Synapse serverless pools consistently lean on one specific collation for reading data lake files:

Latin1_General_100_CI_AI_SC_UTF8
Latin1_General — base locale: English and most Western European languages
100 — rule version; newer than the unversioned default
CI — case-insensitive: "Bronx" equals "bronx"
AI — accent-insensitive: "cafe" equals "café"
SC — supplementary characters supported (emoji, rare scripts)
UTF8 — UTF-8 storage encoding

The UTF8 segment is the one that matters most for this chapter's purposes. Data lake files are typically UTF-8 encoded, and without a UTF-8-aware collation, non-ASCII characters can come back garbled or compare incorrectly.

Setting it once, at the database level

Rather than repeat COLLATE on every VARCHAR column in every query, set it once on the database, and every column created afterward inherits it:

database-level defaultT-SQL
CREATE DATABASE nyc_taxi_discovery;

USE nyc_taxi_discovery;

ALTER DATABASE nyc_taxi_discovery
    COLLATE Latin1_General_100_CI_AI_SC_UTF8;

A per-column override is still available when a specific field needs different rules than the database default:

per-column overrideT-SQL
WITH (
    LocationID    SMALLINT,
    Borough       VARCHAR(15) COLLATE Latin1_General_100_CI_AI_SC_UTF8,
    zone          VARCHAR(15) COLLATE Latin1_General_100_CI_AI_SC_UTF8,
    service_zone  VARCHAR(15) COLLATE Latin1_General_100_CI_AI_SC_UTF8
) AS [r]
Common mistake

COLLATE only applies to character types. Attaching it to a numeric column — LocationID SMALLINT COLLATE ... — is rejected outright, because collation governs text comparison and a number has nothing for that rule set to act on.

Postgres bridge

Postgres has the same underlying concept, but it's usually invisible — a locale and encoding (commonly en_US.UTF-8) get set once at initdb or CREATE DATABASE time and are rarely touched again. T-SQL surfaces this idea far more explicitly, letting it be overridden per database, per column, or even per expression.

7.6

Headerless files and ordinal position

Not every file has a header row. Without column names to match against, the only way to identify a field is by counting its position — its ordinal — left to right, starting at 1.

Set HEADER_ROW = FALSE (or omit it — FALSE is the default), and unnamed columns fall back to a generic C1, C2, C3... naming scheme:

a row in taxi_zone_no_header.csv:
Bronx,Allerton/Pelham Gardens,Boro Zone
C1 → Bronx
C2 → Allerton/Pelham Gardens
C3 → Boro Zone

To assign real names and types, attach the ordinal position directly after each column's type in the WITH clause:

mapping by positionT-SQL
SELECT *
FROM OPENROWSET(
    BULK 'raw/zone_lookup_no_header.csv',
    DATA_SOURCE = 'nyc_taxi_data',
    FORMAT = 'CSV',
    PARSER_VERSION = '2.0'
)
WITH (
    Borough VARCHAR(15) 2,
    Zone    VARCHAR(50) 3
) AS [r];

Position 1 was skipped entirely here — mapping every ordinal position isn't required, only the ones actually needed. Notice, too, that the size mistake from section 7.3 is easy to repeat: Zone VARCHAR(15) is too narrow for "Allerton/Pelham Gardens" and throws the same truncation error, regardless of whether the column was matched by name or by position.

Worth remembering

When the layout of a headerless file is unfamiliar, run SELECT TOP 5 * against it first, with no WITH clause. Seeing the raw C1, C2, C3... columns and their contents makes the ordinal mapping obvious before committing to real names and types.

7.7

Delimiters, quoting, and escaping

"CSV" is really shorthand for delimited text in general. The character that separates fields, the character that wraps a field, and the character that escapes a literal occurrence of either are all independently configurable.

OptionDefaultPurpose
FIELDTERMINATOR,The character that separates one field from the next
FIELDQUOTE"The character that wraps a field so it can safely contain the terminator
ESCAPECHARnoneThe character that marks the next character as literal, not structural
ROWTERMINATORengine defaultThe character (or sequence) that marks the end of a row

Reading a tab-separated file

There is no separate FORMAT='TSV'. A TSV file is a CSV file that happens to use a tab as its delimiter, so the fix is simply to override FIELDTERMINATOR:

tab-delimited, still FORMAT='CSV'T-SQL
SELECT TOP 100 *
FROM OPENROWSET(
    BULK 'raw/trip_type.tsv',
    DATA_SOURCE = 'nyc_taxi_data',
    FORMAT = 'CSV',
    HEADER_ROW = TRUE,
    PARSER_VERSION = '2.0',
    FIELDTERMINATOR = '\t'
)
WITH (
    trip_type   SMALLINT,
    description VARCHAR(50)
) AS [r];
Common mistake

The option is spelled FIELDTERMINATOR — no underscore — even though HEADER_ROW and PARSER_VERSION, sitting right next to it, both use one. Writing FIELD_TERMINATOR fails with a plain syntax error, not a helpful hint about the name:

Started executing query
Incorrect syntax near 'FIELD_TERMINATOR'.

Escaping a quote character embedded in a field

A field that legitimately contains the quote character has to mark it as literal, or the parser will treat it as the end of the field:

raw line inside notes_escaped.csv:
"She said \"stop\" loudly"
parsed field value, once ESCAPECHAR is set correctly:
She said "stop" loudly
ESCAPECHAR = '\\'

The doubled backslash isn't T-SQL escaping anything — it's the literal value being passed to the option, telling the parser: the character used to escape things in this file is a single backslash. Without it set correctly, an embedded quote reads as the field's closing quote and the row breaks apart or throws a size-related error.

7.8

Reusable pointers: CREATE EXTERNAL DATA SOURCE

Typing the full abfss:// path in every single query gets old fast, and it's fragile — one storage account rename and every query breaks. A named data source solves both problems.

registering the pointerT-SQL
CREATE EXTERNAL DATA SOURCE nyc_taxi_data
WITH (
    LOCATION = 'abfss://nyc-taxi-data@<storage_account>.dfs.core.windows.net/'
);

This is metadata only — a saved alias, nothing more. No bytes move, and no copy is made anywhere. Every query that references it still reads the live file from storage at execution time, exactly as it would with a full inline path.

 Without a data sourceWith a data source
BULK path 'abfss://nyc-taxi-data@<storage_account>.dfs.core.windows.net/raw/zone_lookup.csv' 'raw/zone_lookup.csv'
Extra argument none DATA_SOURCE = 'nyc_taxi_data'

Private storage: adding a credential

Public containers need nothing further. A private, secured storage account needs a scoped credential attached to the data source — typically the same managed identity used elsewhere in a Unity Catalog or ADLS setup:

data source with a managed identity credentialT-SQL
CREATE DATABASE SCOPED CREDENTIAL nyc_taxi_credential
WITH (
    IDENTITY = 'Managed Identity'
);

CREATE EXTERNAL DATA SOURCE nyc_taxi_data
WITH (
    LOCATION = 'abfss://nyc-taxi-data@<storage_account>.dfs.core.windows.net/',
    CREDENTIAL = nyc_taxi_credential
);
7.9

Troubleshooting reference

Nearly every error this chapter's syntax produces falls into one of a handful of categories.

MessageCauseFix
File cannot be opened because it does not exist Wrong path, a typo, or the source file has moved or been retired Verify the exact path and container name; list the container's contents if unsure
Schema cannot be determined from data files CSV has no embedded schema, and no WITH clause was supplied Add an explicit WITH clause naming every column and type
String or binary data would be truncated A VARCHAR length is smaller than the widest actual value Check real widths with MAX(LEN(...)), then resize with headroom
Incorrect syntax near '...' An option name is misspelled or missing/extra underscore Check exact spelling — FIELDTERMINATOR has none, HEADER_ROW does
Collation cannot be applied to this data type COLLATE attached to a numeric or other non-character column Remove COLLATE from non-text columns; it only applies to character types
7.10

Putting it together

A single end-to-end pass: register the pointer once, then read two different files against it — one with a header, one without.

step 1 — register the data source, onceT-SQL
CREATE EXTERNAL DATA SOURCE nyc_taxi_data
WITH (
    LOCATION = 'abfss://nyc-taxi-data@<storage_account>.dfs.core.windows.net/'
);
step 2 — a header CSV, typed and collatedT-SQL
SELECT TOP 100 *
FROM OPENROWSET(
    BULK 'raw/zone_lookup.csv',
    DATA_SOURCE = 'nyc_taxi_data',
    FORMAT = 'CSV',
    PARSER_VERSION = '2.0',
    HEADER_ROW = TRUE
)
WITH (
    LocationID    SMALLINT,
    Borough       VARCHAR(15)  COLLATE Latin1_General_100_CI_AI_SC_UTF8,
    Zone          VARCHAR(50)  COLLATE Latin1_General_100_CI_AI_SC_UTF8,
    service_zone  VARCHAR(15)  COLLATE Latin1_General_100_CI_AI_SC_UTF8
) AS [zones];
step 3 — a headerless, tab-delimited file, by ordinal positionT-SQL
SELECT TOP 100 *
FROM OPENROWSET(
    BULK 'raw/trip_type_no_header.tsv',
    DATA_SOURCE = 'nyc_taxi_data',
    FORMAT = 'CSV',
    PARSER_VERSION = '2.0',
    FIELDTERMINATOR = '\t'
)
WITH (
    trip_type   SMALLINT     1,
    description VARCHAR(50)  2
) AS [trip_types];

Same pointer, same underlying engine, two different shapes of source file — one leaning on a header row and named columns, the other reconstructed entirely from position and an explicit delimiter override.

Every technique in this chapter follows from one fact: OPENROWSET reads a file live, and a plain-text file can't describe itself.

  • OPENROWSET is a live read, not an import — nothing is ever copied into the pool.
  • CSV has no embedded schema. A WITH clause is the normal, expected way to query one, not an edge case.
  • Size string columns from a real measurement — MAX(LEN(...)) or sp_describe_first_result_set — rather than a guess.
  • Collation controls comparison rules and character encoding together; set it once at the database level rather than repeating it per column.
  • Exact option spelling matters and isn't consistent: FIELDTERMINATOR has no underscore, HEADER_ROW and PARSER_VERSION do.
  • An external data source is a named pointer, nothing more — it shortens queries, it doesn't move data.