Result output routing
For ordinary command-line usage, begin with TigerSqlCmd concepts and usage. This document defines the detailed engine and file-routing contract.
TigerQuery result output routing lets a SQLCMD script or an embedding application send result sets, normal SQL Server messages, and SQL Server error messages to deterministic files without making the command-line application responsible for parsing directives, serializing rows, or managing file lifetimes.
Routing is opt-in. With no initial output paths and no :Out or :Error directives,
TigerQuery keeps its normal behavior: result sets go to OnResultSet, messages go to
OnMessage, and tiger-sqlcmd renders them in the console. A routed channel is redirected,
not mirrored; its presentation callback is not invoked. Structured logging and batch
lifecycle/progress callbacks remain independent and are never redirected.
Design and ownership
TigerQuery owns routing because directives are part of ordered SQLCMD execution, not a terminal-rendering concern. The engine knows the exact point at which each directive takes effect, the batch/execution/result-set coordinates, SQL message severity and origin, and whether execution is streaming or prepared. Keeping those rules in the engine gives CLI and library consumers the same behavior and prevents each host from implementing subtly different parsing, naming, CSV, error, and cleanup rules.
The division of responsibility is:
- TigerQuery parses and orders
:Outand:Error, tracks run-scoped routes, resolves and reserves paths, writes CSV and message files, and reports output failures. tiger-sqlcmdmaps CLI options toOutputRoutingOptionsand continues to own console rendering and numeric exit-code presentation.- Library hosts configure routing through
TigerQueryEngineOptions.OutputRoutingand receive unrouted payloads through their callbacks.
Ordered execution
TigerQuery represents executable SQLCMD input as ordered route and batch steps. The run
starts with application callbacks, then applies any InitialOutPath and
InitialErrorPath, then consumes script directives and batches in source order. The most
recent directive for a channel wins from its effective position onward:
:Out first.csv
SELECT 1 AS Id;
GO
:Out second.csv
SELECT 2 AS Id;
GO
:Out first.csv
SELECT 3 AS Id;
GO
This produces:
first.csv Id, then rows 1 and 3
second.csv Id, then row 2
Returning to a path already used in the same run continues the existing destination. It
does not write a second byte-order mark or header. :Out never changes the error route;
:Error never changes result-set or normal-message routes.
A directive encountered after SQL text but before that text's terminating GO affects
the buffered batch because the batch has not executed yet:
SELECT 1 AS Id;
:Out selected.csv
GO
Both prepared and streaming execution use the same ordered steps and produce the same
route changes and stable result coordinates. The public batch-only
SqlCmdParser.ReadBatchesAsync API still returns SqlBatch values: it recognizes and
validates output directives but projects the route steps away. Consumers that need
routing must execute through TigerQueryEngine.
Console and file routes
There are three presentation channels:
| Channel | Default destination | File route |
|---|---|---|
| Result sets | TigerQueryEngineOptions.OnResultSet |
InitialOutPath or :Out |
| Normal server diagnostics, severities 0–10 | TigerQueryEngineOptions.OnMessage |
:Out only when OutBehavior is ResultSetsAndNormalMessages |
| Error server diagnostics, severities 11 and above | TigerQueryEngineOptions.OnMessage |
InitialErrorPath or :Error |
Only SQL Server diagnostics are eligible for message files. Parser, connection,
configuration, cancellation, routing, and other engine exception text is not written to
:Error files. This avoids treating unstable exception strings or potentially sensitive
configuration text as script output. Redirected server messages still reach the
configured ILogger.
With OutDirectiveBehavior.ResultSetsOnly, the default, :Out changes only the result-set
route. With ResultSetsAndNormalMessages, result sets use the requested path and normal
messages use a separate text companion formed by appending .messages.log to the complete
resolved path:
report.csv result sets
report.csv.messages.log normal SQL Server messages
The companion is lazy and is not created if no normal messages arrive. Errors remain
controlled independently by :Error.
CSV output
ResultSetOutputFormat.Csv is the built-in structured format. It has a fixed,
portable contract:
- comma delimiter;
- CRLF record endings on every platform;
- one header row per destination schema;
- minimal RFC 4180-compatible quoting;
- doubled quotes inside quoted fields;
- invariant, culture-independent value formatting;
byte[]values as uppercase0xhexadecimal;- round-trip formats for date/time, floating-point, GUID, and duration values;
- SQL
NULLas an empty field.
Headers use the same escaping rules as data. Fields containing commas, quotes, CR, or LF are quoted, and embedded line endings are preserved. CSV files contain no banners, separator records, row-count prose, or server messages.
SQL NULL and an empty string are intentionally indistinguishable in this format. If a
consumer must retain that distinction, use the engine result-set callback and a
consumer-owned format rather than routed CSV.
The requested filename does not select the format and is used exactly in
SingleFile mode. :Out report writes CSV to a file named report; TigerQuery does not
add .csv merely because CSV is selected.
File modes and deterministic names
ResultSetFileMode.SingleFile writes every result set routed to a path into that file.
The first non-zero-column result set writes the header and establishes the schema. Later
result sets must have the same column count and the same column names in the same ordinal
positions, compared ordinally. Compatible rows append without another header; differing
CLR/SQL value types are allowed. An incompatible result set fails before any bytes from
that result set are written, although earlier output remains in the file.
ResultSetFileMode.FilePerResultSet treats the route as a base name and generates one
file for each result set:
<stem>_b<batch>_e<execution>_r<result><extension>
Coordinates are one-based, invariant, and padded to at least four digits. For example,
the second result set in the first execution of batch 1 routed from report.csv becomes:
report_b0001_e0001_r0002.csv
If the base path has no extension, the selected format's .csv extension is added to
generated files. Each file has its own header and there is no cross-file schema
restriction. Coordinates are the engine's original logical coordinates; route changes,
GO repetitions, and skipped zero-column results do not renumber later files. A
zero-column routed result produces no file and no result callback.
Paths, creation, encoding, and overwrite behavior
Relative paths are resolved against OutputRoutingOptions.BaseDirectory, captured once
at run start. When it is null, TigerQuery captures Environment.CurrentDirectory once.
RunFromFileAsync follows the same rule; it does not implicitly use the script's
directory. A library host that wants script-relative output must set BaseDirectory to
that directory explicitly.
Parent directories must already exist. TigerQuery does not create them. Physical paths
are reserved by channel after full resolution, using platform filesystem comparison
semantics. Result, normal-message companion, and error channels may not collide. A known
collision is an OutputRoutingException, even if no payload has yet caused the file to
be created.
Files are opened lazily on their first payload. The first use of a physical path in a run creates or truncates it. Reusing that path later in the same run continues it. Separate runs never append: an existing destination is overwritten on the next run. Output is written directly, not by atomic file replacement, so cancellation or failure can leave a valid partial file containing complete earlier result sets.
The default encoding for every result and message file is UTF-8 with a BOM. A custom
.NET Encoding keeps its BOM preference, but TigerQuery strengthens it with exception
fallbacks. An unencodable value therefore fails instead of being replaced silently.
Spreadsheet and downstream-tool compatibility is the caller's responsibility when a
non-default encoding is selected.
Failure semantics
Invalid paths, channel collisions, missing directories, access denial, sharing
violations, incompatible single-file schemas, serialization or encoding failures, and
flush or close failures become OutputRoutingException. The exception's Path is the
fully resolved target when resolution succeeded, otherwise the originally supplied path.
An output failure is fatal regardless of :ON ERROR and
ContinueOnErrorForUnhandledExceptions. It stops subsequent batches and produces
ExecutionResultCode.OutputFailed; tiger-sqlcmd maps that result to exit code 8.
When SQL Server and output fail contemporaneously, the output failure remains primary and
the SQL exception may be retained as secondary diagnostic context. Earlier SQL side
effects may already be committed, and earlier file content remains as partial output.
tiger-sqlcmd / SqlCmdEx usage
Route an inline query to one CSV file:
tiger-sqlcmd run --non-interactive --connection local `
--query "SELECT Id, Name FROM dbo.Customer ORDER BY Id" `
--output .\exports\customers.csv --format Csv
The exports directory must already exist. -o is an alias for --output.
Set initial result and error routes while allowing later script directives to replace them:
tiger-sqlcmd run --connection local --file .\report.sql --mode SqlCmdEx `
--output .\exports\initial.csv `
--error-output .\exports\sql-errors.log
Write one generated file per provider result set and route normal messages to companion files:
tiger-sqlcmd run --connection local --file .\report.sql --mode SqlCmdEx `
--output .\exports\report.csv `
--output-mode FilePerResultSet `
--out-behavior ResultSetsAndNormalMessages `
--output-encoding utf-8
Available aliases are:
| Purpose | Options |
|---|---|
| Initial result path | -o, --output |
| Initial error path | -e, --error-output |
| Format | --format, --result-format |
| File mode | --output-mode, --result-file-mode |
| Encoding | --output-encoding, --encoding |
:Out channel behavior |
--out-behavior |
A script can switch destinations between batches:
:Out customers.csv
SELECT customer.Id, customer.Name, status.Name AS StatusName
FROM dbo.Customer AS customer
JOIN dbo.Status AS status ON status.Id = customer.StatusId
ORDER BY customer.Id;
GO
:Out projects.csv
SELECT project.Id, project.Name, status.Name AS StatusName
FROM dbo.Project AS project
JOIN dbo.Status AS status ON status.Id = project.StatusId
ORDER BY project.Id;
GO
Run it with SQLCMD extensions enabled:
tiger-sqlcmd run --non-interactive --connection local `
--file .\route-results.sql --mode SqlCmdEx --format Csv
Relative directive paths resolve from the process working directory captured at run
start. Each :Out affects its following batch, so the joined and explicitly ordered
queries land in separate deterministic CSV files.
Library API usage
Library consumers configure the reusable engine behavior directly:
using ItTiger.TigerQuery.Engine;
using System.Text;
var options = new TigerQueryEngineOptions
{
ConnectionString = connectionString,
Mode = SqlCmdMode.SqlCmdEx,
OutputRouting = new OutputRoutingOptions
{
BaseDirectory = Path.Combine(workDirectory, "exports"),
InitialOutPath = "initial.csv",
InitialErrorPath = "errors.log",
OutBehavior = OutDirectiveBehavior.ResultSetsAndNormalMessages,
ResultSetFileMode = ResultSetFileMode.SingleFile,
ResultSetFormat = ResultSetOutputFormat.Csv,
FileEncoding = new UTF8Encoding(
encoderShouldEmitUTF8Identifier: true,
throwOnInvalidBytes: true),
AllowScriptOutputDirectives = true
},
OnResultSet = result => RenderUnroutedResult(result),
OnMessage = (message, isException) => RenderUnroutedMessage(message)
};
var result = await new TigerQueryEngine(options)
.RunFromFileAsync(scriptPath, cancellationToken);
if (result.ResultCode == ExecutionResultCode.OutputFailed)
{
var failure = (OutputRoutingException)result.Exception!;
Console.Error.WriteLine($"Output failed for '{failure.Path}'.");
}
Set AllowScriptOutputDirectives to false in restricted or service hosts that must
control every destination. Initial host routes still work, but encountering :Out or
:Error becomes a parser error rather than being ignored.
Limitations and deterministic-output guidance
- CSV is the only built-in routed result-set format, and its delimiter, CRLF endings, header, and null representation are fixed.
- Single-file routing requires identical column names and order across all result sets
sent to the same physical path. Use
FilePerResultSetfor heterogeneous schemas. - File routing is not transactional with SQL execution and not an atomic file publish. Write into a run-owned directory and promote files only after a successful engine result when consumers require all-or-nothing publication.
- Always use
ORDER BYwhen row order matters. TigerQuery preserves provider order but cannot create an order the SQL query did not request. - Use explicit aliases for stable column names, and keep culture-independent CSV conversion in mind when comparing golden files.
- Set
BaseDirectoryexplicitly in services, tests, and build jobs. This prevents output locations from changing with the caller's working directory. - Do not route different payload channels to the same path. Companion paths are reserved
too, so avoid choosing an error file such as
report.csv.messages.logwhen that companion can be generated. - Direct parser consumers do not receive route steps. Execute through
TigerQueryEnginefor implemented routing semantics.
Live E2E coverage
TigerSqlCmdCsvRoutingLiveTests.ThreeBatchesRouteJoinedResultsToSeparateCsvFiles proves
the complete CLI path against SQL Server. The test creates related status, customer,
project, and work-item tables in an exactly owned E2E database, runs a real child
tiger-sqlcmd process in SqlCmdEx mode, and executes three ordered join queries separated
by GO, each preceded by a different :Out directive.
The test parses customers.csv, projects.csv, and work-items.csv with CsvHelper and
asserts exact headers and row order. It also proves that informational messages and row
counts do not contaminate CSV, no unexpected CSV or companion files are created, every
output stays in the run-owned directory, and the generated database/profile are cleaned
up through exact-instance lifecycle ownership.