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.

Intermediate90–115 minutesT-SQL batches + NULL semantics labSQL Server 2025 CU7 check · 17.0.4065.4SSMS/sqlcmd-aware batching · free local serverLast reviewed: August 2026

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.

01

Explain the difference between a Transact-SQL statement, a batch, a client-side GO separator, and an application command sent to the Database Engine.

02

Use local variables and expressions while respecting batch scope and statement boundaries.

03

Reason about NULL with SQL three-valued logic: TRUE, FALSE, and UNKNOWN.

04

Predict how WHERE, CASE, CHECK constraints, and comparisons treat UNKNOWN rather than treating NULL as an ordinary value.

05

Diagnose a script that succeeds in SSMS/sqlcmd but fails when sent unchanged through an application driver.

Continuity from Chapters 01–02

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.

sql · prove variable scope ends with the batch
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.

What SQL Server is doing

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.

sql · typed variables and expressions
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.

sql · observe UNKNOWN in ordinary comparisons
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.

sql · see CHECK and NULL interact
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.

Diagnosis

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.

Safer repair

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.

sql · batch and three-valued-logic evidence card
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.

sql · cleanup the disposable object
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 = NULL is not the same as IS 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

  1. Why can a script containing GO work in SSMS but fail when sent as one application command?
  2. What happens to a local variable after a GO recognized by the client?
  3. Why does WHERE closed_at = NULL not return rows whose closed_at is NULL?
  4. Why can NULL pass CHECK (score > 0) when the column is nullable?
  5. 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

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.