# SET NOCOUNT ON et les performances

> SET NOCOUNT ON accélère-t-il une procédure ? Octets, paquets et temps mesurés sur SQL Server 2025, avec une latence et un débit réseau simulés, et face au traitement ligne à ligne.
>
> Source : https://www.pachadata.com/docs/questions/set-nocount-on-performances/
> Auteur : Rudi Bruchez · publié le 2026-11-07 · mis à jour le 2026-11-09
> Version Markdown de la page, pour les outils et les LLM. La page HTML fait référence.


*Article coécrit avec Claude, qui a mené les tests et les recherches.*

> TL;DR :
>
> Sans `SET NOCOUNT ON`, une procédure qui fait 100 000 `UPDATE` dans une boucle envoie 3,9 Mo au client ; avec, 75 octets. Ces messages partent dans des paquets pleins, à la suite, sans attendre de réponse : il n'y a donc pas d'impact sur les performances de l'exécution des requêtes. Mais ils coûtent du débit.


## La question

Une bonne pratique pour les procédures stockées est de toujours les commencer par l'instruction `SET NOCOUNT ON`, qui empêche SQL Server d'envoyer au client, après chaque instruction, le message qui donne le nombre de lignes touchées (le « (1 row affected) » de SSMS).
Ce message est le jeton `DONE_IN_PROC` du protocole TDS. La [documentation de `SET NOCOUNT`](https://learn.microsoft.com/fr-fr/sql/t-sql/statements/set-nocount-transact-sql) dit qu'il « peut améliorer sensiblement les performances » des procédures qui contiennent des boucles, parce que le trafic réseau est fortement réduit. Celle de [`CREATE PROCEDURE`](https://learn.microsoft.com/fr-fr/sql/t-sql/statements/create-procedure-transact-sql) dit au contraire qu'il n'y a pas de bénéfice mesurable sur le matériel actuel.

Qu'est-ce que `SET NOCOUNT ON` fait gagner, et dans quels cas ? Et peut-on s'en servir pour accélérer un traitement ligne à ligne (*RBAR*, *row by agonizing row*) ?

## Le laboratoire

SQL Server 2025 CU10 (17.0.5035.5), édition Developer, conteneur Linux jetable limité à 3 Go. Une base `q_nocount` en récupération simple, une table de 1 000 lignes, et deux procédures identiques à une ligne près : chacune fait `@n` `UPDATE` d'une ligne dans une boucle `WHILE`, à l'intérieur d'une transaction, et seule la seconde commence par `SET NOCOUNT ON`.

Le client est un programme Python (`pymssql` 2.4.6) qui tourne sur l'hôte. Il passe par un relais TCP écrit pour l'occasion, qui compte les octets envoyés par le serveur et peut retarder chaque segment de 10 ms dans chaque sens (20 ms d'aller-retour), puis limiter le débit du serveur vers le client à 10 Mbit/s. Le nombre de paquets écrits par le serveur vient de la colonne `num_writes` de `sys.dm_exec_connections`, relevée avant et après la mesure. Chaque mesure a été faite trois fois. Le script complet est en fin de page.

## Le trafic envoyé au client

| Appel | Octets reçus par le client | Paquets écrits par le serveur |
|---|---|---|
| 1 appel, 100 000 `UPDATE`, sans `SET NOCOUNT ON` | 3 907 759 | 956 |
| 1 appel, 100 000 `UPDATE`, avec | 75 | 2 |
| 1 000 appels de 10 `UPDATE`, sans | 496 021 | 1 001 |
| 1 000 appels de 10 `UPDATE`, avec | 54 021 | 1 001 |

Sans `SET NOCOUNT ON`, chaque itération de la boucle coûte 39 octets au client. La [spécification de TDS](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tds/43e891c5-f7a1-432f-8f9f-233c4cd96afb) donne 13 octets à un jeton `DONE_IN_PROC`, et dit qu'il en part un pour chaque instruction exécutée dans une procédure. Trois jetons par tour, c'est le compte des instructions de la boucle : l'`UPDATE`, l'incrément de `@i` et le test du `WHILE`. Je n'ai pas décodé le flux pour le vérifier. `SET NOCOUNT ON` les supprime tous, y compris ceux des instructions qui ne touchent aucune ligne.

Le nombre de paquets montre comment ils partent. Les 3,9 Mo font 956 paquets, soit des paquets pleins de 4 096 octets, la taille par défaut : le serveur ne vide pas son tampon à chaque instruction, il remplit un paquet et l'envoie. Quand la procédure est appelée 1 000 fois avec 10 instructions, chaque réponse tient dans un paquet, avec ou sans les messages : 1 001 paquets dans les deux cas.

## La latence et le débit

Temps mesuré par le client, trois exécutions :

| Appel | Réseau | Sans `SET NOCOUNT ON` | Avec |
|---|---|---|---|
| 1 appel, 100 000 `UPDATE` | relais sans délai | 0,41 à 0,76 s | 0,38 à 0,73 s |
| 1 appel, 100 000 `UPDATE` | 20 ms d'aller-retour | 0,45 à 0,48 s | 0,42 à 0,44 s |
| 1 appel, 100 000 `UPDATE` | 20 ms et 10 Mbit/s | 3,92 à 3,96 s | 0,42 à 0,71 s |
| 1 000 appels de 10 `UPDATE` | relais sans délai | 2,02 à 2,16 s | 2,00 à 2,18 s |
| 1 000 appels de 10 `UPDATE` | 20 ms d'aller-retour | 28,5 à 31,0 s | 30,2 à 41,1 s |
| 1 000 appels de 10 `UPDATE` | 20 ms et 10 Mbit/s | 31,0 à 32,1 s | 29,9 à 32,8 s |

La latence ne change rien : les messages suivent les résultats dans le même flux, et le client n'a pas à répondre à chacun. Les 1 000 appels coûtent 1 000 allers-retours dans les deux variantes, et c'est ce qui fait les 30 secondes.

Le débit change tout dès que le volume est grand. 3,9 Mo à 10 Mbit/s demandent un peu plus de 3 secondes de transmission, et c'est ce que l'appel a pris en plus. Pour une procédure appelée souvent avec peu d'instructions, l'écart est de 442 octets par appel, invisible même à 10 Mbit/s.

## Le temps serveur et le traitement ligne à ligne

Même table, cette fois de 100 000 lignes, chacune mise à jour une fois. Le client est `sqlcmd`, dans le conteneur, et les temps sont ceux de `sys.dm_exec_procedure_stats`, sur cinq exécutions :

| Procédure | Temps moyen | Minimum et maximum | Temps processeur moyen |
|---|---|---|---|
| boucle de 100 000 `UPDATE`, sans `SET NOCOUNT ON` | 399 ms | 389 à 406 ms | 398 ms |
| boucle de 100 000 `UPDATE`, avec | 402 ms | 388 à 412 ms | 401 ms |
| un seul `UPDATE` des 100 000 lignes | 97 ms | 85 à 136 ms | 97 ms |

Une seconde série de cinq exécutions, lancée plus tard avec le script de cette page tel qu'il est publié, a donné des temps plus longs sur une machine plus chargée : 684 ms sans `SET NOCOUNT ON`, 714 ms avec, et 126 ms pour l'`UPDATE` ensembliste. L'écart entre les deux boucles reste du bruit, dans un sens puis dans l'autre.

Pour le serveur, produire les messages ne coûte rien de mesurable. Avec `SET NOCOUNT ON`, la boucle reste aussi lente ; l'option retire les 39 octets par ligne traitée envoyés au client, qui ne comptent que sur un lien lent. Le même travail écrit en une instruction ensembliste est quatre à cinq fois plus rapide, avec ou sans l'option.

Il y a deux bonnes raisons pour mettre `SET NOCOUNT ON` en tête de toutes les procédures :

1. diminuer le trafic réseau inutile ;
2. sans lui, le nombre de lignes que reçoit l'application additionne celles de toutes les instructions, déclencheurs compris. Dans un déclencheur qui fait deux instructions, `ExecuteNonQuery` a rendu 3 pour un `INSERT` d'une ligne, au lieu de 1.

## Limites des mesures

- Mesuré sur une seule version, SQL Server 2025 CU10, sur Linux, et un seul pilote, `pymssql`. Un client qui traite chaque message plus lentement, comme la fenêtre de messages de SSMS, n'a pas été mesuré.
- Le réseau est simulé par un relais en Python sur la même machine. Sa limite de débit retarde les données après les avoir lues : le serveur n'a jamais attendu le client (pas d'attente `ASYNC_NETWORK_IO`), alors que sur un vrai lien lent il attendrait.
- La taille de paquet est celle par défaut, 4 096 octets.
- Je n'ai pas décodé le flux TDS : les trois jetons par itération de la boucle sont déduits de la taille et de la spécification, pas observés.

## Le script

Le script crée et supprime sa base jetable. Il contient un `DBCC FREEPROCCACHE`, qui vide le cache des plans de toute l'instance : à ne lancer que sur une instance de test.

```sql
-- SET NOCOUNT ON : temps serveur d'une boucle, avec et sans, face à un UPDATE ensembliste
SET NOCOUNT ON;
USE master;
IF DB_ID(N'q_nocount') IS NOT NULL
BEGIN
    ALTER DATABASE q_nocount SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
    DROP DATABASE q_nocount;
END;
CREATE DATABASE q_nocount;
ALTER DATABASE q_nocount SET RECOVERY SIMPLE;
GO
USE q_nocount;
CREATE TABLE dbo.Compteur (id int PRIMARY KEY, valeur int NOT NULL);
INSERT dbo.Compteur (id, valeur)
SELECT TOP (1000) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)), 0 FROM sys.all_columns;
GO
-- Une procédure qui exécute @n UPDATE d'une ligne, dans une transaction
CREATE OR ALTER PROCEDURE dbo.BoucleSansNocount @n int AS
BEGIN
    DECLARE @i int = 0;
    BEGIN TRAN;
    WHILE @i < @n BEGIN UPDATE dbo.Compteur SET valeur += 1 WHERE id = 1 + @i % 1000; SET @i += 1; END;
    COMMIT;
END;
GO
CREATE OR ALTER PROCEDURE dbo.BoucleAvecNocount @n int AS
BEGIN
    SET NOCOUNT ON;
    DECLARE @i int = 0;
    BEGIN TRAN;
    WHILE @i < @n BEGIN UPDATE dbo.Compteur SET valeur += 1 WHERE id = 1 + @i % 1000; SET @i += 1; END;
    COMMIT;
END;
GO

DROP TABLE IF EXISTS dbo.Ligne;
CREATE TABLE dbo.Ligne (id int PRIMARY KEY, valeur int NOT NULL);
INSERT dbo.Ligne SELECT TOP (100000) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)), 0 FROM sys.all_columns a CROSS JOIN sys.all_columns b;
GO
CREATE OR ALTER PROCEDURE dbo.LigneParLigneSansNocount AS
BEGIN
    DECLARE @i int = 1;
    BEGIN TRAN;
    WHILE @i <= 100000 BEGIN UPDATE dbo.Ligne SET valeur += 1 WHERE id = @i; SET @i += 1; END;
    COMMIT;
END;
GO
CREATE OR ALTER PROCEDURE dbo.LigneParLigneAvecNocount AS
BEGIN
    SET NOCOUNT ON;
    DECLARE @i int = 1;
    BEGIN TRAN;
    WHILE @i <= 100000 BEGIN UPDATE dbo.Ligne SET valeur += 1 WHERE id = @i; SET @i += 1; END;
    COMMIT;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Ensembliste AS
BEGIN
    SET NOCOUNT ON;
    UPDATE dbo.Ligne SET valeur += 1;
END;
GO
DBCC FREEPROCCACHE;  -- conteneur jetable : remet les statistiques des procédures à zéro
GO
EXEC dbo.LigneParLigneSansNocount;
EXEC dbo.LigneParLigneAvecNocount;
EXEC dbo.Ensembliste;
GO 5
SELECT OBJECT_NAME(object_id) AS proc_, execution_count,
       total_elapsed_time / execution_count / 1000 AS ms_moyen,
       min_elapsed_time / 1000 AS ms_min, max_elapsed_time / 1000 AS ms_max,
       total_worker_time / execution_count / 1000 AS cpu_ms_moyen
FROM sys.dm_exec_procedure_stats WHERE database_id = DB_ID();
GO
```

Le programme Python qui mesure les octets, les paquets et les temps à travers le relais, à lancer après le script précédent. Il se connecte au port 11990 de la machine locale et lit le mot de passe de `sa` dans la variable `SQLCMDPASSWORD`.

```python
"""Mesure de SET NOCOUNT ON : temps client, octets reçus, paquets écrits par le serveur.
Un relais TCP local ajoute un délai fixe dans chaque sens (latence simulée) et compte les octets."""
import asyncio, os, sys, time, threading, statistics
import pymssql

CIBLE = ("127.0.0.1", 11990)
recu = 0  # octets serveur -> client

async def relais(lecteur, ecrivain, delai, compter, debit=None):
    global recu
    file = asyncio.Queue()
    async def ecrire():
        while True:
            t, data = await file.get()
            if data is None:
                ecrivain.close(); return
            attente = t - time.monotonic()
            if attente > 0:
                await asyncio.sleep(attente)
            ecrivain.write(data); await ecrivain.drain()
            if debit:  # octets par seconde : on attend le temps de transmission du segment
                await asyncio.sleep(len(data) / debit)
    tache = asyncio.create_task(ecrire())
    while True:
        data = await lecteur.read(65536)
        if not data:
            await file.put((0, None)); break
        if compter:
            recu += len(data)
        await file.put((time.monotonic() + delai, data))
    await tache

def lancer_relais(port, delai, debit=None):
    async def client(r, w):
        sr, sw = await asyncio.open_connection(*CIBLE)
        await asyncio.gather(relais(r, sw, delai, False), relais(sr, w, delai, True, debit), return_exceptions=True)
    async def main():
        srv = await asyncio.start_server(client, "127.0.0.1", port)
        async with srv: await srv.serve_forever()
    threading.Thread(target=lambda: asyncio.run(main()), daemon=True).start()
    time.sleep(0.3)

def mesure(port, sql, appels):
    global recu
    cn = pymssql.connect(server="127.0.0.1", port=port, user="sa", password=os.environ["SQLCMDPASSWORD"], database="q_nocount", autocommit=True)
    cn.cursor().execute("EXEC dbo.BoucleAvecNocount @n = 1")  # chauffe
    cur = cn.cursor()
    cur.execute("SELECT num_writes FROM sys.dm_exec_connections WHERE session_id = @@SPID"); w0 = cur.fetchone()[0]
    recu = 0
    t0 = time.perf_counter()
    for _ in range(appels):
        cur.execute(sql)
        while cur.nextset():
            pass
    dt = time.perf_counter() - t0
    octets = recu
    cur.execute("SELECT num_writes FROM sys.dm_exec_connections WHERE session_id = @@SPID"); w1 = cur.fetchone()[0]
    cn.close()
    return dt, octets, w1 - w0 - 1  # -1 : la réponse du premier relevé

if __name__ == "__main__":
    lancer_relais(12000, 0.0)     # relais sans délai
    lancer_relais(12010, 0.010)   # 10 ms par sens, 20 ms aller-retour
    lancer_relais(12020, 0.010, 10e6 / 8)  # idem, et 10 Mbit/s du serveur vers le client
    scenarios = [
        ("1 appel, 100 000 UPDATE", 100000, 1),
        ("1 000 appels, 10 UPDATE", 10, 1000),
    ]
    passes = int(sys.argv[1]) if len(sys.argv) > 1 else 3
    for nom, n, appels in scenarios:
        for port, reseau in ((12000, "relais 0 ms"), (12010, "relais 10 ms"), (12020, "10 ms + 10 Mbit/s")):
            for proc in ("BoucleSansNocount", "BoucleAvecNocount"):
                res = [mesure(port, f"EXEC dbo.{proc} @n = {n}", appels) for _ in range(passes)]
                temps = ", ".join(f"{r[0]:.2f}" for r in res)
                print(f"{nom} | {reseau} | {proc} | {temps} s | {res[-1][1]} octets | {res[-1][2]} paquets", flush=True)
```

Pour finir, la suppression de la base :

```sql
USE master;
ALTER DATABASE q_nocount SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
DROP DATABASE q_nocount;
```


---

## L'auteur

Rudi Bruchez, consultant SQL Server indépendant depuis 2006 : audit de performance, optimisation, migrations, haute disponibilité et formation. Pour un audit ou une intervention sur votre serveur : https://www.pachadata.com/contact/ (audit à distance : https://www.pachadata.com/services/audit-sql-server-a-distance/).
