Skip to content

Three quiet fidelity limits in a T-SQL result grid

Wide integers and high-scale decimals arrive as JavaScript numbers, only the first result set of a batch is read, and one paged statement is not recognised.

A result grid that is wrong loudly is a bug report. A result grid that is wrong quietly is a decision someone will make on Tuesday. The SQL Server provider in LibreDB Studio has three such quiet places, and all three are written down on the provider page rather than smoothed over. Two of them hand back a plausible wrong answer instead of an error, and the third fails a statement that looks correct.

How values cross the wire into the grid

The SQL Server provider is built on the mssql driver (node-mssql, Tedious over TDS), default port 1433, in src/lib/db/providers/sql/mssql.ts. Running a statement takes a Request from the pool, binds parameters as @p1, @p2 and so on through request.input(), and returns one object:

{ rows: recordset, fields, rowCount: rowsAffected[0] ?? recordset.length, executionTime, columnTypes? }

Two things in that shape matter for the rest of this post. rows is result.recordset - singular. And columnTypes is filled from the map the driver attaches to the recordset, using T-SQL’s own lowercase spelling of the declared type: bigint, decimal, nvarchar, uniqueidentifier, varbinary. That is declaration, not the driver’s class name, because INFORMATION_SCHEMA.COLUMNS.DATA_TYPE answers bigint and not BigInt, so the type on a computed column reads the same as the type in the schema tree. It was measured on SQL Server 2022 CU26 over a probe table.

Everything after the driver is JSON. That is where the fidelity goes.

Exact numerics that stop being exact: bigint and decimal precision loss at the client

BIGINT, DECIMAL, NUMERIC and MONEY are surfaced as JavaScript numbers. Beyond 2^53, or at high scale, they can lose precision. That is the whole of it: no error, no warning marker on the cell, a number that is very close to the right one.

The failure has a shape worth recognising. A BIGINT primary key generated by a snowflake scheme sits comfortably above 2^53, so two adjacent ids can render as the same digit string in the grid. A DECIMAL(19,4) ledger column sums correctly on the server and arrives as a binary float, so the total you copy out of the grid and the total the server computed can differ in the last place. Both look like data. Neither looks like a defect.

Preserving them means fetching them as strings, and the provider does not do that for you. What it does give you is the declared type beside the column, which is the signal to stop trusting the digits: if columnTypes says bigint, decimal, numeric or money, the value in that cell went through a JavaScript number on the way to your screen. The statement can do what the provider does not, by casting the column to NVARCHAR before it leaves the server:

SELECT
  CAST(order_id AS NVARCHAR(20)) AS order_id_text,
  CAST(amount   AS NVARCHAR(32)) AS amount_text
FROM sales.orders;

The same inference problem exists on the way in. Parameters are bound with request.input() and no SQL type, so the driver infers the TDS type from the JavaScript value. The provider doc records that inference as a foot-gun and names three values it can guess wrong: null, very large integers, and VARCHAR against NVARCHAR intent. Binding a type explicitly is not currently exposed.

The batch that returns more than you see

query() reads result.recordset - singular, the first result set and no other. So a multi-statement batch, or a stored procedure that returns several result sets, surfaces exactly one of them in the grid.

The server does not skip the rest. It runs the entire batch; what the provider hands on is the first set, and the others do not reach the grid. Run this and the grid shows the customers:

SELECT id, name FROM dbo.customers;
SELECT id, total FROM dbo.orders;

There is no row saying a second set existed, because nothing in the returned shape records that one did. The count under the grid does not help either - rowCount is rowsAffected[0], the first statement’s affected-row count, falling back to the length of the recordset. On a batch, that is a number about the first statement sitting under a grid you may be reading as the whole answer.

The workaround is the one it sounds like: run the statements as separate tabs, or call the procedure in a way that returns one set. It is a real limitation, not a style preference about batches, and it is on the provider page in those words.

A paged statement the limiter does not recognise

Before a SELECT reaches the server it passes a limiter, so an unbounded query does not pull a table into a browser. On T-SQL that means one of two splices: TOP n into the head after SELECT [DISTINCT], or OFFSET m ROWS FETCH NEXT n ROWS ONLY appended at the tail, with ORDER BY (SELECT NULL) injected when the statement has no ordering of its own, because T-SQL requires one there.

To avoid doubling a bound that already exists, the limiter first asks whether the statement is already paged. Those probes read a literal count. So this is recognised and left alone:

SELECT id, name FROM dbo.customers ORDER BY id
OFFSET 10 ROWS FETCH NEXT 50 ROWS ONLY;

and this is not:

SELECT id, name FROM dbo.customers ORDER BY id
OFFSET @skip ROWS FETCH NEXT @take ROWS ONLY;

The parameterised page still reads as unbounded, so it collects a TOP - and SQL Server rejects a TOP beside an OFFSET ... FETCH outright, with Msg 10741. The statement fails rather than returning too many rows. The provider doc records this as a known limitation rather than as a decision, and names the fix: accept a variable or an expression as the count.

The limiter is conservative in the same direction elsewhere. Where it cannot read a statement’s end confidently, it declines to splice and reports no limit rather than reporting one it did not apply. It never claims a bound while handing the statement back unchanged.

Binary columns, and what they cost in response size

VARBINARY, IMAGE and rowversion are not stringified by the provider. They come back as Node buffers and cross the wire as {"type":"Buffer","data":[...]}. The client recovers them: the results grid, the row detail sheet and the CSV export all classify that shape as binary and render it as \x hex, in src/lib/export/binary.ts.

So this one is not a correctness limit. It is a size limit. A byte of binary data travels as roughly four bytes of JSON digits, which means a SELECT * over a table with a photo column is a much larger response than the row count suggests. Project the columns you want, or wrap the binary in a length or a hash on the server when all you need is to tell two rows apart.

Wide integers, decimals and money values are surfaced as JavaScript numbers and can lose precision; only one result set of a multi-statement batch is surfaced; and a parameterised paged statement is not recognised as already bounded, so the statement fails. Those three sentences are the SQL Server entry’s cost of admission. They are on the engine pages and in the published feature boundaries, and we would rather you read them now than reconcile a ledger against them later.