When connecting to a distributed SQL cluster like YugabyteDB using Npgsql, it’s easy to assume that MaxPoolSize places a global limit on the number of active database connections.
However, when using Load Balance Hosts=true, Npgsql creates one pool per host, which means the real maximum number of connections is:
- MaxPoolSize × number_of_hosts
This tip explains:
● Why this happens
● How to measure it accurately
● How to enforce a true global max connection limit
● And provides a demo program you can run immediately
🧰 Part 1: The Demo Program (C# / Npgsql)
Before looking at examples, here is the complete program that:
● Discovers cluster nodes via
yb_servers()● Opens connections using Npgsql
● Load-balances across YB nodes
● Enforces per-host
MaxPoolSize● Monitors per-node backend counts
● Prints a clean summary + detailed logfile
👉 Copy/paste this into Program.cs
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Generic;
using Npgsql;
class Program
{
// Counters
private static int _started;
private static int _succeeded;
private static int _failed;
private static long _maxMonBackends;
// Logging
private static string? _logFile = null;
private static readonly object _logLock = new();
static void Log(string msg)
{
if (_logFile == null)
{
Console.WriteLine(msg);
return;
}
lock (_logLock)
{
File.AppendAllText(_logFile, msg + Environment.NewLine);
}
}
static async Task Main(string[] args)
{
// -----------------------
// DEFAULT CONFIG
// -----------------------
string seedHost = "";
int workers = -1;
int maxPool = -1;
bool loadBalance = false;
int sleepSeconds = 10;
int timeoutSeconds = 5;
string? logfile = null;
// -----------------------
// PARSE NAMED PARAMETERS
// -----------------------
foreach (var arg in args)
{
if (arg == "--help")
{
PrintHelp();
return;
}
if (arg.StartsWith("--seed="))
seedHost = arg.Substring("--seed=".Length);
else if (arg.StartsWith("--workers="))
int.TryParse(arg.Substring("--workers=".Length), out workers);
else if (arg.StartsWith("--maxpool="))
int.TryParse(arg.Substring("--maxpool=".Length), out maxPool);
else if (arg == "--lb")
loadBalance = true;
else if (arg.StartsWith("--logfile="))
{
logfile = arg.Substring("--logfile=".Length);
try
{
File.WriteAllText(logfile, "=== Log Start ===\n");
_logFile = logfile;
}
catch (Exception ex)
{
Console.WriteLine($"Failed to open logfile '{logfile}': {ex.Message}");
_logFile = null;
}
}
else if (arg.StartsWith("--sleep="))
int.TryParse(arg.Substring("--sleep=".Length), out sleepSeconds);
else if (arg.StartsWith("--timeout="))
int.TryParse(arg.Substring("--timeout=".Length), out timeoutSeconds);
else
{
Console.WriteLine($"Unknown parameter: {arg}");
PrintHelp();
return;
}
}
// -----------------------
// VALIDATION
// -----------------------
if (string.IsNullOrWhiteSpace(seedHost) ||
workers <= 0 ||
maxPool <= 0)
{
PrintHelp();
return;
}
// Fixed DB settings
int port = 5433;
string dbName = "yugabyte";
string dbUser = "yugabyte";
string dbPass = "yugabyte";
string workerAppName = "YbNpgsqlPoolDemo";
string monitorAppName = "YbNpgsqlPoolDemo_MON";
// -----------------------
// DISCOVER CLUSTER HOSTS
// -----------------------
List allHosts = await DiscoverClusterAsync(
seedHost, port, dbName, dbUser, dbPass, monitorAppName);
List workloadHosts =
loadBalance ? allHosts : new List { seedHost };
string hostList = string.Join(",", workloadHosts);
string lbClause = loadBalance ? "Load Balance Hosts=true;" : "";
// -----------------------
// BUILD CONNECTION STRING
// -----------------------
string endpointBase =
$"Host={hostList};Port={port};Database={dbName};Username={dbUser};Password={dbPass};{lbClause}";
string pooledConnString =
endpointBase +
$"Application Name={workerAppName};Maximum Pool Size={maxPool};Timeout={timeoutSeconds};";
Log("=== FULL LOG START ===");
Log($"Seed host = {seedHost}");
Log($"Workers = {workers}");
Log($"MaxPool = {maxPool}");
Log($"Load balance = {loadBalance}");
Log($"SleepSeconds = {sleepSeconds}");
Log($"TimeoutSeconds = {timeoutSeconds}");
Log($"Discovered hosts = {string.Join(",", allHosts)}");
Log($"Workload host list = {hostList}");
Log($"Worker connstring = {pooledConnString}");
// -----------------------
// PRINT CLEAN SUMMARY HEADER
// -----------------------
Console.WriteLine("=== Npgsql/YugabyteDB Connection Pool Demo ===");
Console.WriteLine($"Seed host : {seedHost}");
Console.WriteLine($"Workers : {workers}");
Console.WriteLine($"Max pool size : {maxPool}");
Console.WriteLine($"Load balancing : {loadBalance}");
Console.WriteLine($"Discovered hosts : {string.Join(",", allHosts)}");
Console.WriteLine($"Workload hosts : {hostList}");
Console.WriteLine($"Sleep (seconds) : {sleepSeconds}");
Console.WriteLine($"Timeout (seconds) : {timeoutSeconds}");
if (logfile != null)
Console.WriteLine($"Logfile : {logfile}");
Console.WriteLine();
// -----------------------
// START WORKERS & MONITOR
// -----------------------
var cts = new CancellationTokenSource();
var monitorTask = MonitorClusterAsync(
allHosts, port, dbName, dbUser, dbPass,
workerAppName, monitorAppName, cts.Token);
var workerTasks = new Task[workers];
for (int i = 0; i < workers; i++)
{
int id = i;
workerTasks[i] = Task.Run(() =>
WorkerAsync(id, pooledConnString, sleepSeconds), cts.Token);
}
await Task.WhenAll(workerTasks);
cts.Cancel();
try { await monitorTask; } catch { }
// -----------------------
// FINAL PER-NODE COUNTS
// -----------------------
var perNode = await PerNodeCountsAsync(
allHosts, port, dbName, dbUser, dbPass,
workerAppName, monitorAppName);
// -----------------------
// FINAL SUMMARY
// -----------------------
Console.WriteLine("=== Summary (App-side) ===");
Console.WriteLine($"Started : {_started}");
Console.WriteLine($"Succeeded : {_succeeded}");
Console.WriteLine($"Failed : {_failed}");
Console.WriteLine("\n=== Summary (DB-side, via pg_stat_activity per node) ===");
Console.WriteLine($"Max concurrent backend count across cluster: {_maxMonBackends}");
foreach (var kvp in perNode)
Console.WriteLine($"Host {kvp.Key} = {kvp.Value} backend(s)");
if (logfile != null)
Console.WriteLine($"\nFull log written to: {logfile}");
}
// ------------------------------------------------------
// WORKER THREAD
// ------------------------------------------------------
private static async Task WorkerAsync(int id, string connString, int sleepSeconds)
{
int started = Interlocked.Increment(ref _started);
Log($"[WORKER {id:00}] STARTED (#{started})");
try
{
await using var conn = new NpgsqlConnection(connString);
await conn.OpenAsync();
int succ = Interlocked.Increment(ref _succeeded);
Log($"[WORKER {id:00}] GOT CONNECTION (succ={succ})");
string sql = $"select pg_sleep({sleepSeconds});";
await using var cmd = new NpgsqlCommand(sql, conn);
await cmd.ExecuteNonQueryAsync();
Log($"[WORKER {id:00}] FINISHED");
}
catch (Exception ex)
{
int f = Interlocked.Increment(ref _failed);
Log($"[WORKER {id:00}] FAILED (#{f}): {ex.Message}");
}
}
// ------------------------------------------------------
// CLUSTER DISCOVERY (yb_servers)
// ------------------------------------------------------
private static async Task> DiscoverClusterAsync(
string seed, int port, string db, string user, string pass, string appName)
{
var hosts = new List();
string connStr =
$"Host={seed};Port={port};Database={db};Username={user};Password={pass};" +
$"Application Name={appName}_DISC;Pooling=false;";
try
{
await using var conn = new NpgsqlConnection(connStr);
await conn.OpenAsync();
const string sql = "select host from yb_servers();";
await using var cmd = new NpgsqlCommand(sql, conn);
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
if (!reader.IsDBNull(0))
{
string h = reader.GetString(0).Trim();
if (!string.IsNullOrEmpty(h))
hosts.Add(h);
}
}
}
catch (Exception ex)
{
Log($"[DISCOVERY] Failed: {ex.Message}");
}
if (hosts.Count == 0)
{
Log($"[DISCOVERY] No results from yb_servers(); using seed host {seed}");
hosts.Add(seed);
}
return hosts;
}
// ------------------------------------------------------
// CLUSTER-WIDE MONITOR
// ------------------------------------------------------
private static async Task MonitorClusterAsync(
List hosts, int port, string db, string user, string pass,
string workerApp, string monitorApp, CancellationToken token)
{
const int intervalMs = 1000;
while (!token.IsCancellationRequested)
{
long total = 0;
foreach (var h in hosts)
{
string connStr =
$"Host={h};Port={port};Database={db};Username={user};Password={pass};" +
$"Application Name={monitorApp};Pooling=false;";
try
{
await using var conn = new NpgsqlConnection(connStr);
await conn.OpenAsync(token);
const string sql =
"select count(*) from pg_stat_activity where application_name=@app;";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("app", workerApp);
long count = (long)(await cmd.ExecuteScalarAsync(token))!;
total += count;
}
catch
{
// Ignore per-node monitor errors in this loop
}
}
if (total > _maxMonBackends)
_maxMonBackends = total;
Log($"[MON] cluster-active-backends = {total}");
try { await Task.Delay(intervalMs, token); }
catch { break; }
}
Log("[MON] stopped");
}
// ------------------------------------------------------
// FINAL PER-NODE COUNTS
// ------------------------------------------------------
private static async Task> PerNodeCountsAsync(
List hosts, int port, string db, string user, string pass,
string workerApp, string monitorApp)
{
var result = new Dictionary();
foreach (var h in hosts)
{
string connStr =
$"Host={h};Port={port};Database={db};Username={user};Password={pass};" +
$"Application Name={monitorApp}_SUMMARY;Pooling=false;";
try
{
await using var conn = new NpgsqlConnection(connStr);
await conn.OpenAsync();
const string sql =
"select count(*) from pg_stat_activity where application_name=@app;";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("app", workerApp);
long count = (long)(await cmd.ExecuteScalarAsync())!;
Log($"[SUMMARY] Host {h} has {count} backend(s)");
result[h] = count;
}
catch (Exception ex)
{
Log($"[SUMMARY] Failed on {h}: {ex.Message}");
result[h] = -1;
}
}
return result;
}
// ------------------------------------------------------
// HELP TEXT
// ------------------------------------------------------
private static void PrintHelp()
{
Console.WriteLine();
Console.WriteLine("Usage:");
Console.WriteLine(" dotnet run -- \\");
Console.WriteLine(" --seed= REQUIRED YSQL seed host for discovery");
Console.WriteLine(" --workers= REQUIRED Number of workers");
Console.WriteLine(" --maxpool= REQUIRED Max Npgsql pool size");
Console.WriteLine();
Console.WriteLine("Optional flags:");
Console.WriteLine(" --lb Enable load balance across YB nodes");
Console.WriteLine(" --logfile= Save detailed logs to file");
Console.WriteLine(" --sleep= How long workers hold the connection (default 10)");
Console.WriteLine(" --timeout= Npgsql connection timeout (default 5)");
Console.WriteLine();
Console.WriteLine("Example:");
Console.WriteLine(" dotnet run -- --seed=127.0.0.1 --workers=20 --maxpool=5 --lb --logfile=run.log");
Console.WriteLine();
}
}
▶️ Part 2: Running the Program
Prerequisites
You’ll need:
● A running YugabyteDB cluster with YSQL enabled (port 5433).
● Network access from your dev box to the cluster.
● .NET 6+ SDK.
● The YugabyteDB Npgsql Smart Driver (
NpgsqlYugabyteDBpackage).
We’ll assume:
● Database:
yugabyte● User:
yugabyte● Password:
yugabyte● Port:
5433
Here’s an example run:
dotnet run -- \
--seed=127.0.0.1 \
--workers=10 \
--maxpool=5 \
--lb \
--logfile=lb.log
What the flags mean:
| Flag | Description |
|---|---|
| –seed | Any one YSQL host for discovering cluster topology |
| –workers | Number of concurrent sessions to attempt |
| –maxpool | Npgsql per-host pool limit |
| –lb | Enable Npgsql client-side load balancing |
| –logfile | Save detailed worker/monitor logs |
📊 Part 3: Example Output
=== Summary (App-side) ===
Started : 10
Succeeded : 10
Failed : 0
=== Summary (DB-side, via pg_stat_activity per node) ===
Max concurrent backend count across cluster: 10
Host 127.0.0.1 = 4 backend(s)
Host 127.0.0.2 = 3 backend(s)
Host 127.0.0.3 = 3 backend(s)
🔍 Part 4: What This Means
You set:
●
MaxPoolSize = 5● Load balancing enabled across 3 nodes
● 10 workers
Npgsql created three separate pools (one per host):
Host 127.0.0.1 → up to 5 connections
Host 127.0.0.2 → up to 5 connections
Host 127.0.0.3 → up to 5 connections
------------------------------------
Global potential → up to 15 connections
In your run:
● All 10 workers obtained connections
● No pool hit its per-node limit of 5
● You observed 4+3+3 = 10 global connections
Which is exactly how Npgsql operates.
🧮 Part 5: Enforcing a True Global Max Connection Limit
If you want your application to have, say, no more than 5 total connections across the entire cluster, you have two options:
Option A: Disable load balancing
Use a single host target:
--seed=127.0.0.1 # No --lb
Npgsql creates one pool → MaxPoolSize becomes global.
Example:
[root@localhost YbNpgsqlPoolDemo]# dotnet run -- --seed=127.0.0.1 --workers=10 --maxpool=5 --logfile=nolb.log
=== Npgsql/YugabyteDB Connection Pool Demo ===
Seed host : 127.0.0.1
Workers : 10
Max pool size : 5
Load balancing : False
Discovered hosts : 127.0.0.1,127.0.0.2,127.0.0.3
Workload hosts : 127.0.0.1
Sleep (seconds) : 10
Timeout (seconds) : 5
Logfile : nolb.log
=== Summary (App-side) ===
Started : 10
Succeeded : 5
Failed : 5
=== Summary (DB-side, via pg_stat_activity per node) ===
Max concurrent backend count across cluster: 5
Host 127.0.0.1 = 5 backend(s)
Host 127.0.0.2 = 0 backend(s)
Host 127.0.0.3 = 0 backend(s)
Full log written to: nolb.log
Option B: Use load balancing but reduce per-host pool size
To target a global cap of ~6 connections on a 3-node cluster:
--maxpool=2 # 3 hosts × 2 = ~6 global max
This preserves load balancing while lowering global connection usage.
Example:
[root@localhost YbNpgsqlPoolDemo]# dotnet run -- --seed=127.0.0.1 --workers=10 --maxpool=2 --lb --logfile=lb.log
=== Npgsql/YugabyteDB Connection Pool Demo ===
Seed host : 127.0.0.1
Workers : 10
Max pool size : 2
Load balancing : True
Discovered hosts : 127.0.0.1,127.0.0.2,127.0.0.3
Workload hosts : 127.0.0.1,127.0.0.2,127.0.0.3
Sleep (seconds) : 10
Timeout (seconds) : 5
Logfile : lb.log
=== Summary (App-side) ===
Started : 10
Succeeded : 10
Failed : 0
=== Summary (DB-side, via pg_stat_activity per node) ===
Max concurrent backend count across cluster: 6
Host 127.0.0.1 = 1 backend(s)
Host 127.0.0.2 = 1 backend(s)
Host 127.0.0.3 = 0 backend(s)
Full log written to: lb.log
🏁 Part 6: Summary
| Topic | Key Point |
|---|---|
| Npgsql + LB | Creates one pool per host. |
| MaxPoolSize | Applies per node, not per cluster. |
| Global connections | Approximately MaxPoolSize × number_of_hosts. |
| Enforcing global cap | Disable load balancing or reduce MaxPoolSize. |
| YugabyteDB monitoring | Must query each node’s pg_stat_activity individually. |
Have Fun!
