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 pool | Dedicated SQL pool | |
|---|---|---|
| Where data lives | Stays in the data lake | Physically loaded into the pool |
| Compute | Spun up per query | Always-on, provisioned capacity |
| Billing | Per TB of data processed | Per hour of reserved compute |
| Typical use | Ad hoc exploration, data lake queries, external tables | Large-scale, high-concurrency production warehousing |
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.
Anatomy of OPENROWSET
OPENROWSET is the function that does the actual reading. Everything else in this chapter is really just options passed into it.
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];
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.
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.
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.
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.
The explicit schema: column names, types, and — for headerless files — ordinal positions. Required for CSV in almost every real scenario.
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.
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.
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:
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:
The right habit is to check the real maximum length before committing to a size, rather than guess and re-run:
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.
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 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.
VARCHAR vs. NVARCHAR
Both store text. The difference is which characters they can safely hold, and how much storage each character costs.
| VARCHAR | NVARCHAR | |
|---|---|---|
| Character support | Limited to the column's collation | Full Unicode |
| Storage per character | 1 byte | 2 bytes |
| Typical use | Codes, IDs, plain Latin/English text | Free 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.
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.
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:
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:
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:
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]
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 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.
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:
To assign real names and types, attach the ordinal position directly after each column's type in the WITH clause:
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.
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.
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.
| Option | Default | Purpose |
|---|---|---|
| FIELDTERMINATOR | , | The character that separates one field from the next |
| FIELDQUOTE | " | The character that wraps a field so it can safely contain the terminator |
| ESCAPECHAR | none | The character that marks the next character as literal, not structural |
| ROWTERMINATOR | engine default | The 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:
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];
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:
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:
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.
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.
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 source | With 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:
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
);
Troubleshooting reference
Nearly every error this chapter's syntax produces falls into one of a handful of categories.
| Message | Cause | Fix |
|---|---|---|
| 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 |
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.
CREATE EXTERNAL DATA SOURCE nyc_taxi_data
WITH (
LOCATION = 'abfss://nyc-taxi-data@<storage_account>.dfs.core.windows.net/'
);
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];
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.
OPENROWSETis a live read, not an import — nothing is ever copied into the pool.- CSV has no embedded schema. A
WITHclause is the normal, expected way to query one, not an edge case. - Size string columns from a real measurement —
MAX(LEN(...))orsp_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:
FIELDTERMINATORhas no underscore,HEADER_ROWandPARSER_VERSIONdo. - An external data source is a named pointer, nothing more — it shortens queries, it doesn't move data.