Collecting Windows Performance Counters Reliably with PDH
Build reliable Windows counter collectors with PDH queries, localized counter paths, warm-up samples, per-counter status checks, and bounded cleanup.
Windows performance counters provide an administrative and diagnostic view of system and application behavior. The Performance Data Helper (PDH) API is the high-level C/C++ interface Microsoft recommends for most counter collection tasks because it handles counter-path parsing, provider interaction, instance matching, and formatted-value calculations. PDH is useful for periodic monitoring and incident evidence; Microsoft explicitly cautions that performance counters are not designed for high-frequency profiling. For detailed execution timelines, use ETW/WPR instead.
The consumer model consists of a query, one or more counters, repeated collections, and formatted or raw samples. Counter paths include the machine, object, instance, and counter name. A query can be local or remote, logged or real-time, but remote access depends on services and authentication configuration that may be disabled by default. Decide whether a collector should fail closed when a counter is absent or continue with partial telemetry before deploying it to many systems.
Counter names are not universal strings
English counter names and localized display names differ across Windows languages. A hard-coded path such as \\Processor(_Total)\\% Processor Time can fail on a non-English installation. PdhAddEnglishCounterW accepts English counter names and is useful for language-independent tooling where supported by the target Windows version; otherwise enumerate or translate counter names using the PDH name-lookup APIs and store the resolved path. Do not assume every expected object, counter, or instance exists on every machine, edition, role, or workload.
Wildcard paths can add multiple matching counters, while an instance that does not yet exist can still be accepted by PdhAddCounter. Therefore, PdhAddCounter success means the query accepted the path, not that a meaningful sample will be available. Record the status returned with every formatted value and distinguish “counter unavailable,” “instance not yet created,” and a numeric zero. They are operationally different states.
Query lifecycle and sampling interval
The sample below creates a local query, adds a counter, collects two samples, checks the formatted status, and closes both handles on every ordinary path. It uses the English-language counter helper to avoid relying on the display language. The counter is an example; validate the object and counter path on the target machines. Rate counters generally require two observations and a meaningful interval before a rate is calculated.
#define UNICODE
#define _UNICODE
#include <windows.h>
#include <pdh.h>
#include <stdio.h>
#pragma comment(lib, "pdh.lib")
int main(void)
{
PDH_HQUERY query = NULL;
PDH_HCOUNTER counter = NULL;
PDH_STATUS status = PdhOpenQueryW(NULL, 0, &query);
if (status != ERROR_SUCCESS) {
fwprintf(stderr, L"PdhOpenQueryW failed: 0x%08lx\n", status);
return 1;
}
status = PdhAddEnglishCounterW(
query, L"\\Processor(_Total)\\% Processor Time", 0, &counter);
if (status != ERROR_SUCCESS) {
fwprintf(stderr, L"PdhAddEnglishCounterW failed: 0x%08lx\n", status);
PdhCloseQuery(query);
return 1;
}
status = PdhCollectQueryData(query); /* Baseline sample for rate counters. */
if (status != ERROR_SUCCESS) {
fwprintf(stderr, L"initial collection failed: 0x%08lx\n", status);
PdhCloseQuery(query);
return 1;
}
Sleep(1000); /* Choose an interval appropriate to the monitoring objective. */
status = PdhCollectQueryData(query);
if (status != ERROR_SUCCESS) {
fwprintf(stderr, L"second collection failed: 0x%08lx\n", status);
PdhCloseQuery(query);
return 1;
}
PDH_FMT_COUNTERVALUE value = {0};
DWORD counterType = 0;
status = PdhGetFormattedCounterValue(
counter, PDH_FMT_DOUBLE, &counterType, &value);
if (status != ERROR_SUCCESS ||
(value.CStatus != PDH_CSTATUS_VALID_DATA &&
value.CStatus != PDH_CSTATUS_NEW_DATA)) {
fwprintf(stderr, L"counter sample unavailable: api=0x%08lx data=0x%08lx\n",
status, value.CStatus);
PdhCloseQuery(query); /* Also closes counters added to this query. */
return 1;
}
wprintf(L"CPU total: %.2f%%\n", value.doubleValue);
PdhCloseQuery(query);
return 0;
}
This compact example reports a single sample and intentionally does not implement a service-grade scheduler, remote collection, or logging. Production code should use a monotonic schedule, track collection timestamps and elapsed intervals, and avoid accumulating drift by sleeping a fixed interval after slow processing. If the query contains several counters, collect and report status per counter; one valid result does not make the whole query healthy. Close a query only after its consumer is done, because closing it also releases counters added to that query.
Rates, instances, and counter semantics
Many counters are raw cumulative values; others are rates, fractions, or averages computed from multiple samples. Do not apply a generic delta formula to every counter. Ask PDH for a formatted value using the counter’s declared type, and retain raw values when an audit trail or independent recomputation is required. A first sample for a rate counter may be unavailable because there is no prior sample. A process can exit and a new process can inherit a reused instance name or index; Microsoft documents that some legacy process counter sets may misattribute values in this situation. Windows 11’s Process V2 counter set addresses that specific instance-identification problem; validate counter-set availability before switching dashboards.
Counter values are best interpreted as time-series evidence. A short spike can disappear in a one-minute average; a long collection interval can hide a brief queue saturation event. Conversely, polling very rapidly increases overhead and creates more data without turning counters into a profiler. Select a resolution that matches the operational question, document it, and keep the same resolution in the baseline and incident comparison.
Deployment and troubleshooting checklist
Test a collector under a standard user and its actual service identity. Some providers or remote paths require privileges or network configuration; do not solve access problems by granting local administrator rights reflexively. Confirm the machine name syntax, counter-path escaping, language behavior, available instances, and counter status. Use Performance Monitor or typeperf as an independent check when a custom program reports an unexpected value. Preserve counter path, status code, timestamp, sampling interval, and host identity with each measurement.
PDH is a practical interface for low-rate observability and diagnostic collection, provided the collector treats missing data and counter semantics as first-class states. A graph of numbers is useful only when the number’s definition, origin, and validity are also known.
Related:
- Event Tracing for Windows (ETW): The Kernel’s Built-In Instrumentation System
- Windows Performance Recorder and Analyzer: A Defensible Trace Workflow
Sources: