K6 Load Test
k6-loadtest is a K6-based SQL Server load tester with a Python preprocessor. It turns parquet files (or synthetic data specs) into a K6 test bundle: CREATE TABLE DDL, chunked JSON payloads, a K6 test.js, and a manifest. Point it at a SQL Server (local Docker, on-prem VM, Azure SQL) and it exercises insert, update, and delete workloads under a configurable virtual-user (VU) profile.
Why the extra preprocessing step and a custom K6 binary? SQL Server support in K6 does not ship out of the box — it comes via the xk6-sql extension plus Grafana's xk6-sql-driver-sqlserver driver, both of which have to be compiled into a custom K6 binary using xk6. The preprocessor's job is to bridge parquet or synthetic-mode configs into the JSON-and-JavaScript shape K6 actually expects at runtime.
Prerequisites
- Go 1.22+ so
xk6can build the custom K6 binary. Install via the official Go installer or Homebrew (brew install go). - A SQL Server instance to test against. Options include:
- Local Docker container (recommended for a first test — sample
docker-compose.ymlbelow). - SQL Server 2019+ VM on-prem.
- Azure SQL Database or Azure SQL Managed Instance.
- Any other cloud SQL Server. SQL Edge and SQL Server 2022 both work.
- Local Docker container (recommended for a first test — sample
uv syncin the repo root so thek6-preprocessentry point is on your PATH.
5-step setup
1. Build the custom K6 binary
This script runs xk6 build --with github.com/grafana/xk6-sql --with github.com/grafana/xk6-sql-driver-sqlserver --output ./k6. It installs xk6 itself (via go install) if it is not already on your PATH, then produces ./k6-loadtest/k6 — the binary lands next to the build script regardless of where you invoke it from. Build takes 30 seconds to 2 minutes depending on the state of your Go module cache.
Verify with:
2. Copy the sample config
See the Reference → Configuration page for the field-by-field schema. config.sample.yaml itself is also a canonical reference — every option is commented inline.
3. Edit k6-loadtest/config.yaml
At minimum you need to set three things:
- A data source. Pick your source mode (parquet or synthetic — see Source modes below). For a first test, keep the
yellow_tripssynthetic block fromconfig.sample.yaml— it needs no external files. - A target. Set
host,port,database,username, and thepasswordreference (leave it as${MSSQL_PASSWORD}so the value stays out of git). - A scenario. Wire a
targetanddata_sourcetogether and pick your VU profile. A VU is a virtual user — one concurrent SQL connection driving one iteration loop. Ramp up gradually if you're testing a fresh server; ten VUs on a laptop-Docker SQL Server is already a real load.
4. Preprocess data into K6 inputs
The preprocessor writes:
k6-loadtest/output/schema/<datasource>.sql— one CREATE TABLE DDL per data source, with columns mapped from parquet (or synthetic) types to SQL Server types.k6-loadtest/output/data/<datasource>/chunk_*.json— chunked JSON payloads (parquet mode only). Each chunk becomes one bulk-insert batch at runtime. Chunk size is controlled bychunk_size:in the data source config.k6-loadtest/output/test.js— the K6 test script. It loads chunks via K6'sSharedArray(so they are read once and shared across VUs), wires up scenarios, and drives the SQL Server target.k6-loadtest/output/manifest.json— a machine-readable summary of every scenario, its target connection string (with${MSSQL_PASSWORD}preserved for runtime substitution), and its data source metadata.
5. Apply the CREATE TABLE DDL
The generated schema file for a yellow-taxi source looks roughly like this:
Apply it via sqlcmd:
Or paste the file's contents into Azure Data Studio, DBeaver, or SSMS.
6. Run the K6 test
The K6 binary spins up VUs, each opens a SQL Server connection, and every VU iterates through its scenario — inserting, updating, or deleting rows according to the workload mix you configured. K6 prints a live progress banner and, on completion, a summary of every metric (see Interpreting K6 output).
Source modes
k6-loadtest supports two data source modes. Pick per data source in config.yaml under data_sources: <name>: mode:.
mode: parquet — real data from parquet
- Reads real data from parquet files — typically the
raw/mirror produced by the downloader, orraw-normalized/from normalize. - Preserves real ratios, real cardinalities, real value distributions.
- Slower startup: preprocess has to read parquet and chunk into JSON before K6 can start.
chunk_size:shapes the memory/network trade-off — larger chunks mean fewer round-trips but more RAM held by each VU'sSharedArrayslice.- Best for: bulk-load benchmarks that need to mimic the production data path, or reproducing a specific real-world write pattern.
mode: synthetic — K6 generates rows at runtime
- K6 generates rows at runtime from column value ranges declared in
config.yaml(type: int/float/datetime/string/bool/dateplusmin:/max:). - Instant startup — no parquet reading, no preprocessing bottleneck, no chunk files on disk.
- Unlimited scale — every VU generates its own rows independently, so you can never exhaust a fixture.
- No parquet mirror needed, so you can smoke-test a fresh SQL Server without downloading anything first.
- Best for: first-time smoke tests, unbounded-scale runs, and measuring pure SQL Server insertion throughput without being bound by dataset size.
When to use each
- First-time smoke test → synthetic (fast iteration, no downloader dependency).
- Realistic bulk-load benchmark → parquet (real ratios, real cardinalities).
- Testing insertion ceiling under unbounded load → synthetic (VUs never run out of rows).
- Testing insertion plus realistic distribution → parquet.
Sample local SQL Server (Docker)
A minimal docker-compose.yml that gets you a SQL Server 2022 instance on localhost:1433:
Bring it up:
Verify:
Matching config.yaml target stub:
MSSQL_PASSWORD must be exported in the environment when you run K6 (MSSQL_PASSWORD='YourStrong@Passw0rd' ./k6-loadtest/k6 run …). The preprocessor preserves the ${VAR} placeholder in the manifest so K6 does the substitution at runtime, not at preprocess time. This keeps passwords out of the generated test.js and out of your git history.
Config summary
Top-level keys in config.yaml:
data_sources:— dict keyed by source name. Each has amode:(parquetorsynthetic) plus mode-specific keys (path:andchunk_size:for parquet,columns:with type/min/max blocks for synthetic).targets:— dict of connection targets. Each entry declareshost,port,database,username,password(use${MSSQL_PASSWORD}for runtime substitution), andtable.scenarios:— dict of K6 scenarios. Each ties adata_sourceto atarget, sets a workload mix (insert/update/deletepercentages), think-time bounds, and a K6 executor block (executor:,startVUs:,stages:orduration:).
See the Reference → Configuration page for the exhaustive schema. config.sample.yaml in the repo is also a canonical reference.
Interpreting K6 output
At the end of a run, K6 prints a summary block that looks like this:
Line-by-line:
data_received/data_sent— network throughput for the run. SQL Server bulk-insert workloads are send-heavy, sodata_sentwill dwarfdata_received.iteration_duration— how long one scenario iteration took end-to-end (open connection, execute SQL, tear down). Includes think time.iterations— total scenario runs, plus per-second rate. Multiply by rows-per-iteration to reason about total row throughput.sql_query_duration— server-side query latency reported byxk6-sql. This is the number to compare against SQL Server DMV latency to isolate client-side overhead.sql_rows_affected— total rows inserted, updated, or deleted across all iterations.vus/vus_max— active virtual users during the test. For ramping scenarios,vus_maxis your peak concurrency.
Bulk-insert scenarios. iteration_duration should be roughly equal to sql_query_duration — if the gap is large, client-side setup (connection open, JSON parsing) is dominating and you may want larger chunks or fewer VUs. Watch sql_rows_affected / duration for the throughput number to publish.
Query-latency scenarios. For read or update workloads, sql_query_duration p95 and p99 matter more than the mean. Add K6 thresholds to your scenario config to fail the run automatically if p99 regresses beyond your SLO.
For long-running tests, export metrics to InfluxDB (or Prometheus) so you can chart them in Grafana:
Grafana's official K6 dashboards render sql_query_duration percentiles, VU ramps, and iteration rates out of the box.
Troubleshooting
build_k6.shfails withgo: command not found.xk6needs Go on the PATH. Install Go 1.22+ and re-open your shell sogo env GOPATHresolves, then re-run the build script.build_k6.shfails onxk6 build. Usually a stale Go module cache. Clear it withgo clean -modcacheand retry. The build script always fetches the latestxk6-sqlandxk6-sql-driver-sqlserverfrom Grafana's GitHub org.- K6 exits with
unable to connect to SQL Server. Confirm the target is reachable (sqlcmd -S <host> -U <user> -P "$MSSQL_PASSWORD" -Q "SELECT 1"). For Docker on macOS/Windows, uselocalhostfrom the host and the container name from other containers. For Azure SQL, whitelist your client IP in the firewall. sql_query_durationis orders of magnitude higher than expected. You are probably measuring cold-cache first-iteration effects. Run a short warmup scenario before the measured one, or discard the first N iterations when computing your throughput number.iterationsis much lower than expected. Either your think-time is dominating (dropthink_time.max), or the scenario is bottlenecked on SQL Server (check DMVs for waits). Compareiteration_durationagainstsql_query_durationto tell which side is slow.