Chapter 03 · T-SQL Foundations, Data Types, NULL, Conversion, and Expressions
T-SQL Batch Semantics, GO, Variables, Expressions, NULL, and Three-Valued Logic
Build a precise T-SQL execution model for batches, client-side GO separators, local variables, expressions, NULL, and three-valued logic before more advanced querying.
Learning outcomes
A ServiceHub deployment script works in SQL Server Management
Studio (SSMS), but the application team pastes the same text
into a driver call and receives a syntax error near
GO. In a second bug, a report uses
closed_at = NULL and silently returns no open work
orders. Both failures come from treating client scripting
conventions and SQL truth semantics as if they behaved like a
general-purpose programming language. This lesson builds the
T-SQL mental model before later chapters add more complex
queries.
Explain the difference between a Transact-SQL statement, a batch, a client-side GO separator, and an application command sent to the Database Engine.
Use local variables and expressions while respecting batch scope and statement boundaries.
Reason about NULL with SQL three-valued logic: TRUE, FALSE, and UNKNOWN.
Predict how WHERE, CASE, CHECK constraints, and comparisons treat UNKNOWN rather than treating NULL as an ordinary value.
Diagnose a script that succeeds in SSMS/sqlcmd but fails when sent unchanged through an application driver.
The reusable database is ServiceHubLab, with the
ops schema and deterministic work-order data.
Chapter 02 separated instance settings from database settings.
Chapter 03 now separates client-side script parsing from the
T-SQL batches that the Database Engine actually compiles and
executes.
1. Statements are T-SQL; GO belongs to the client
A batch is one or more T-SQL statements sent to
SQL Server together for compilation/execution.
GO is not part of that language. Microsoft
documents it as a command recognized by utilities such as SSMS
and sqlcmd; the client uses it to decide where one
batch ends and the next begins, then sends the batch text
without the GO token to the server.
This distinction explains why a script can contain
GO and run in SSMS while the same string sent
through an ADO.NET/JDBC/ODBC command fails. A driver sends
command text to SQL Server; unless that client implements a
script parser, GO reaches the engine as an unknown
token.
USE ServiceHubLab;GODECLARE @work_order_id bigint = 1001;SELECT @work_order_id AS first_batch_value;GO-- Run this line to observe the failure after GO created a new batch.SELECT @work_order_id AS second_batch_value;GO
The first SELECT returns 1001. The
second batch should fail because the local variable existed only
in the preceding batch. The lesson is not “avoid GO”; it is
“know which layer owns it.” Use GO in supported
script tools when batching is useful, but do not embed it
blindly inside one application command.
SQL Server compiles the text it receives as a batch. The local
variable lives in that batch’s scope. The client’s decision to
split text at GO creates two independent
submissions, so the second compilation has no declaration for
@work_order_id.
2. Local variables hold values; they do not become schema
A T-SQL local variable begins with @, has a
declared SQL Server data type, and exists only in its
batch/procedure scope. Variable assignment is useful for
intermediate values and parameters, but variables are not
persisted table columns and their names are not visible to
another session.
USE ServiceHubLab;GODECLARE @priority tinyint = 2, @opened datetime2(0) = '2026-08-10T08:00:00', @sla_minutes int = 120;SELECT @priority AS priority, @opened AS opened_at, DATEADD(minute, @sla_minutes, @opened) AS due_at, @sla_minutes / 60.0 AS sla_hours;GO
Expression result types matter. The literal 60.0 is
not the same type as integer 60; later lessons
inspect data-type precedence and precision. For now, treat every
expression as producing both a value and a SQL data type.
3. NULL means missing/unknown—not zero, empty string, or a magic value
SQL NULL represents the absence of a known value.
Because the value is unknown, ordinary equality/inequality
comparisons against NULL do not become TRUE or FALSE; they
become UNKNOWN. SQL therefore uses three-valued
logic. In a WHERE predicate, only rows whose
predicate is TRUE survive; FALSE and UNKNOWN are both filtered
out.
USE ServiceHubLab;GOSELECT work_order_id, status, closed_atFROM ops.WorkOrderWHERE closed_at = NULL;GOSELECT work_order_id, status, closed_atFROM ops.WorkOrderWHERE closed_at IS NULLORDER BY work_order_id;GOSELECT CASE WHEN NULL = NULL THEN 'TRUE' WHEN NOT (NULL = NULL) THEN 'FALSE' ELSE 'UNKNOWN' END AS null_equality_result;GO
The first query should return no rows even when open work orders
have closed_at IS NULL. Use
IS NULL and IS NOT NULL when testing
nullness. Do not revive old ANSI_NULLS OFF habits;
modern SQL Server code should be written for ANSI NULL
semantics.
4. UNKNOWN behaves differently across language contexts
Three-valued logic becomes important when rules move from a
query predicate into a constraint. A WHERE clause
keeps TRUE. A CHECK constraint rejects FALSE, but
UNKNOWN can satisfy the constraint. That is why a nullable
column can still admit NULL even if a check says the non-null
values must be positive.
USE ServiceHubLab;GOIF SCHEMA_ID(N'lab03') IS NULL EXEC(N'CREATE SCHEMA lab03 AUTHORIZATION dbo;');GODROP TABLE IF EXISTS lab03.NullProbe;GOCREATE TABLE lab03.NullProbe( probe_id int IDENTITY PRIMARY KEY, score int NULL, CONSTRAINT CK_NullProbe_Positive CHECK (score > 0));GOINSERT lab03.NullProbe(score) VALUES (5), (NULL);GOSELECT * FROM lab03.NullProbe ORDER BY probe_id;GO-- This one is rejected because the predicate is FALSE.-- INSERT lab03.NullProbe(score) VALUES (-1);GO
If the business rule is “score is mandatory and must be
positive,” the column needs NOT NULL in addition to
the check. Schema rules should encode both nullability and value
constraints explicitly.
5. Deliberately wrong approach: send an SSMS script as one driver command
An application reads a deployment file containing
USE ServiceHubLab; GO CREATE TABLE ... and passes
the entire file to ExecuteNonQuery. The server
reports a syntax error at GO. The developer
“repairs” the file by replacing every GO with
semicolons. That can create a different bug because a batch
separator and a statement terminator are not interchangeable.
Ask who parses the script. SSMS/sqlcmd understand
GO; ordinary database-driver command APIs
generally do not. A semicolon terminates statements inside one
batch. GO causes a supporting client to submit a
new batch, which changes variable scope and can affect
statements that must be first in a batch.
Use a migration/deployment tool that explicitly supports SQL Server batch separators, or split trusted deployment scripts with a correct parser. For runtime application work, send parameterized T-SQL commands/procedure calls rather than ad hoc multi-batch deployment scripts.
6. Hands-on lab: make batch and NULL behavior observable
This lab modifies only a disposable lab03 object
and reads the established work-order dataset. Run it in SSMS or
sqlcmd so GO is intentionally
supported. If you use another client, run one batch at a time
and record that difference instead of pretending the client
recognizes GO.
USE ServiceHubLab;GOSELECT SERVERPROPERTY('ProductVersion') AS engine_version, DB_NAME() AS database_name, DATABASEPROPERTYEX(DB_NAME(), 'Collation') AS database_collation;GODECLARE @open_count int;SELECT @open_count = COUNT(*)FROM ops.WorkOrderWHERE closed_at IS NULL;SELECT @open_count AS open_work_orders;GOSELECT SUM(CASE WHEN closed_at IS NULL THEN 1 ELSE 0 END) AS null_closed_at, SUM(CASE WHEN closed_at IS NOT NULL THEN 1 ELSE 0 END) AS nonnull_closed_at, COUNT(*) AS total_rowsFROM ops.WorkOrder;GOSELECT work_order_id, closed_atFROM ops.WorkOrderWHERE NOT (closed_at = '2026-08-10T12:00:00')ORDER BY work_order_id;GO
The final predicate is deliberately subtle: rows with
closed_at IS NULL produce UNKNOWN for the equality
comparison, and NOT UNKNOWN remains UNKNOWN, so
those rows do not pass WHERE. If the requirement is
“closed at a different time or still open,” write that
requirement explicitly with OR closed_at IS NULL.
USE ServiceHubLab;GODROP TABLE IF EXISTS lab03.NullProbe;GO
Verification checklist
- You can state which tool parsed each
GO. - You reproduced local-variable scope ending at a batch boundary.
-
You can explain why
= NULLis not the same asIS NULL. -
You can explain why a nullable column can pass
CHECK (score > 0)with NULL. - You did not change the reusable ServiceHub schema or database settings.
7. Production judgment and next bridge
Production T-SQL should make client/server boundaries and NULL intent obvious. Deployment scripts should declare which parser they require; application commands should be parameterized and should not depend on SSMS-only behavior. Schema definitions should encode nullability separately from value rules. Query predicates should treat missing information explicitly rather than assuming two-valued Boolean logic.
Lesson 2 now asks a deeper question: once a value exists, which
SQL Server type should represent it? Choosing
float versus decimal,
varchar versus nvarchar, or
datetime2 versus
datetimeoffset changes precision, storage,
comparison, interoperability, and future index behavior.
Check your understanding
- Why can a script containing GO work in SSMS but fail when sent as one application command?
- What happens to a local variable after a GO recognized by the client?
- Why does WHERE closed_at = NULL not return rows whose closed_at is NULL?
- Why can NULL pass CHECK (score > 0) when the column is nullable?
- When should an application use a deployment script parser versus a normal parameterized command?
Review the answers
GO is recognized by scripting clients such as
SSMS/sqlcmd, not by the T-SQL parser as a server
statement. An ordinary driver command can send it
literally and receive a syntax error.
The client ends the current batch and submits the next batch separately. Local variables are scoped to the original batch, so the later batch cannot reference them.
Equality with NULL evaluates to UNKNOWN, not TRUE. WHERE
retains only TRUE rows. Use IS NULL to test
nullness.
A CHECK constraint rejects FALSE; an UNKNOWN result is not
FALSE. If NULL is forbidden, add NOT NULL as
a separate schema rule.
Use a batch-aware deployment/migration tool for trusted multi-batch DDL scripts. Runtime application work should normally send focused parameterized T-SQL/procedure calls.
Authoritative references
- GO utility command — client-side batch separator semantics and local-variable batch scope
- Transact-SQL data types — typed values and expressions
- SQL Server 2025 build versions — current 17.x servicing baseline checked for the lesson