Start with the billing window: a 95th-percentile bandwidth bill is built from sampled rates, not from total transferred bytes and not from the single highest spike. A common contract records five-minute inbound and outbound rates, discards a defined highest 5% tail, keeps the largest surviving value under its direction rule, and compares that result with the committed Mbps.
The arithmetic is short. Contracts create the divergence: providers may treat directions separately, take the larger direction within every interval, aggregate ports, round the discarded count differently, or define missing data and attack traffic in their own terms. Reproduce the provider’s exact rule before choosing a commit.
Use this article if you buy dedicated-server or colocation capacity and can export CSV and run Python 3. No switch configuration or production traffic change is required. The worked lab uses synthetic, secret-free data so every row and failure control can be inspected; replace it with an authorized export only after the calculator matches the provider’s written method.
Three numbers that often appear beside one another answer different questions:
Stackscale’s billing-method explanation describes the familiar 30-day example: 8,640 five-minute samples and roughly 432 discarded values. HostDime’s provider-specific calculation adds an important detail: its source is the switch port serving the equipment, and its billing window follows the actual calendar month. Those facts are contract inputs, not universal defaults.
Short bursts can disappear from the billed tail. Sustained busy periods cannot. If a campaign, backup, replication job, or stream stays above the commit for longer than the discarded fraction, some high samples survive and raise the billed rate. Averages hide that boundary because they spread a busy period across every quiet interval.
Contract reading follows the discipline in hosting uptime SLA calculations: identify the measurement owner, window, inclusion rules, exclusions, and remedy before treating a percentage as comparable.
Ask for a raw or exportable sample history and write one rule sheet beside it. A dashboard graph is helpful for orientation, but a screenshot cannot reveal every row used for the invoice.
| Contract field | What to record | Why it changes the result |
|---|---|---|
| Meter and interval | Port/interface, UTC timestamps, average or peak, exact seconds | Different sources and windows create different rates |
| Billing cycle | Start/end timestamps and expected row count | February, a 30-day period, and a partial first month are not equivalent |
| Direction rule | Separate percentiles then larger; per-interval maximum; sum; billed direction only | Non-overlapping inbound and outbound bursts can produce very different bills |
| Tail and rounding | Exactly how many rows are removed and how fractional counts round | A boundary sample may become the highest survivor or be discarded |
| Commit and overage | Included Mbps, rounding unit, currency, per-Mbps rate or tier | The percentile alone is not the invoice |
| Exceptions | Missing samples, maintenance, scrubbing, attacks, pooled ports, canceled service | Silent substitution can move the ranking and contract liability |
Run repeatable route-and-direction speed tests to answer whether a path can carry a workload. Do not substitute a few active tests for the provider’s billing ledger: a speed test measures selected moments, whereas billing needs the complete declared window.
When counters originate from SNMP, interface identity and counter width matter. RFC 2863 defines high-capacity ifHCInOctets and ifHCOutOctets for interfaces where 32-bit counters can wrap too quickly. The collector still owns counter resets, timestamp alignment, conversion to bits, and division by the real interval. This lab begins after those rates have been exported.
Keep the blocks in one shell so LAB remains available. The bootstrap refuses an existing path and writes a marker that later cleanup must verify.
set -Eeuo pipefail
LAB=/tmp/voxfor-bandwidth-p95-lab
MARKER="$LAB/.voxfor-bandwidth-p95-lab"
if [[ -e "$LAB" ]]; then
printf 'Refusing existing lab path: %s\n' "$LAB" >&2
exit 1
fi
mkdir -m 700 "$LAB"
printf '%s\n' 'voxfor-bandwidth-p95-lab-v1' > "$MARKER"
python3 --version
Build 300 consecutive UTC rows with exactly 15 inbound burst intervals and a different 15 outbound burst intervals. That separation is deliberate: it exposes direction-policy differences that a single shared spike cannot show.
python3 - "$LAB/traffic.csv" <<'PY'
import csv
import datetime as dt
import sys
target = sys.argv[1]
start = dt.datetime(2026, 8, 1, tzinfo=dt.timezone.utc)
with open(target, "w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow(["timestamp_utc", "in_mbps", "out_mbps"])
for index in range(300):
inbound = 18.0 + (index % 20) * 0.3
outbound = 30.0 + (index % 30) * 0.4
if 40 <= index < 55:
inbound = 180.0 + (index - 40)
if 140 <= index < 155:
outbound = 140.0 + (index - 140)
stamp = start + dt.timedelta(minutes=5 * index)
writer.writerow([
stamp.isoformat().replace("+00:00", "Z"),
f"{inbound:.2f}",
f"{outbound:.2f}",
])
print(f"fixture={target} samples=300 interval_seconds=300")
PY
Next, implement one declared provider-style rule: remove ceil(N × 0.05) highest values and choose the next survivor. That is not interchangeable with every statistical software default. Confirm the provider’s tail and rounding method, then change the named rule rather than silently relying on interpolation.
cat > "$LAB/calculate_p95.py" <<'PY'
#!/usr/bin/env python3
import argparse
import csv
import datetime as dt
import math
import pathlib
def parse_utc(value):
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.utcoffset() != dt.timedelta(0):
raise ValueError(f"timestamp is not UTC: {value}")
return parsed
def highest_survivor(values, discard_count):
ordered = sorted(values)
if discard_count <= 0 or discard_count >= len(ordered):
raise ValueError("discard count leaves no auditable survivor set")
return ordered[-discard_count - 1]
parser = argparse.ArgumentParser()
parser.add_argument("csv_path", type=pathlib.Path)
parser.add_argument("--expected-samples", type=int, required=True)
parser.add_argument("--interval-seconds", type=int, default=300)
parser.add_argument("--commit-mbps", type=float, required=True)
parser.add_argument("--overage-per-mbps", type=float, default=0.0)
parser.add_argument("--show-discarded", action="store_true")
args = parser.parse_args()
with args.csv_path.open(newline="", encoding="utf-8") as handle:
rows = list(csv.DictReader(handle))
required = {"timestamp_utc", "in_mbps", "out_mbps"}
if not rows or not required.issubset(rows[0]):
raise SystemExit("ERROR schema must contain timestamp_utc,in_mbps,out_mbps")
if len(rows) != args.expected_samples:
raise SystemExit(
f"ERROR expected {args.expected_samples} samples, found {len(rows)}"
)
timestamps = [parse_utc(row["timestamp_utc"]) for row in rows]
if len(set(timestamps)) != len(timestamps):
raise SystemExit("ERROR duplicate timestamp")
steps = [
(right - left).total_seconds()
for left, right in zip(timestamps, timestamps[1:])
]
if any(step != args.interval_seconds for step in steps):
raise SystemExit(
f"ERROR expected a constant {args.interval_seconds}-second interval"
)
inbound = [float(row["in_mbps"]) for row in rows]
outbound = [float(row["out_mbps"]) for row in rows]
if any(value < 0 or not math.isfinite(value) for value in inbound + outbound):
raise SystemExit("ERROR rates must be finite non-negative Mbps")
discard_count = math.ceil(len(rows) * 0.05)
p95_in = highest_survivor(inbound, discard_count)
p95_out = highest_survivor(outbound, discard_count)
separate_then_max = max(p95_in, p95_out)
interval_max = highest_survivor(
[max(i, o) for i, o in zip(inbound, outbound)], discard_count
)
average_total = sum(i + o for i, o in zip(inbound, outbound)) / len(rows)
observed_gb = sum(
(i + o) * 1_000_000 / 8 * args.interval_seconds
for i, o in zip(inbound, outbound)
) / 1_000_000_000
sustained_30d_tb = (
separate_then_max * 1_000_000 / 8 * 30 * 86400 / 1_000_000_000_000
)
overage = max(0.0, separate_then_max - args.commit_mbps)
print(
f"samples={len(rows)} interval_seconds={args.interval_seconds} "
f"discarded_each_direction={discard_count}"
)
print(f"p95_in_mbps={p95_in:.2f} p95_out_mbps={p95_out:.2f}")
print(f"billable_separate_then_max_mbps={separate_then_max:.2f}")
print(f"alternative_per_interval_max_mbps={interval_max:.2f}")
print(
f"average_total_mbps={average_total:.2f} "
f"observed_transfer_gb={observed_gb:.3f}"
)
print(f"p95_if_sustained_30d_tb={sustained_30d_tb:.3f}")
print(
f"commit_mbps={args.commit_mbps:.2f} overage_mbps={overage:.2f} "
f"overage_cost={overage * args.overage_per_mbps:.2f}"
)
if args.show_discarded:
inbound_tail = sorted(inbound, reverse=True)[:discard_count]
outbound_tail = sorted(outbound, reverse=True)[:discard_count]
print(
f"discarded_in_range={min(inbound_tail):.2f}..{max(inbound_tail):.2f}"
)
print(
f"discarded_out_range={min(outbound_tail):.2f}..{max(outbound_tail):.2f}"
)
PY
chmod 700 "$LAB/calculate_p95.py"
python3 -m py_compile "$LAB/calculate_p95.py"
Run the baseline with a 50 Mbps commit and display the removed ranges. Saving the receipt makes the arithmetic reviewable without rerunning a future, possibly changed export.
python3 "$LAB/calculate_p95.py" "$LAB/traffic.csv" \
--expected-samples 300 \
--interval-seconds 300 \
--commit-mbps 50 \
--overage-per-mbps 7.50 \
--show-discarded | tee "$LAB/receipt.txt"
Below is a representative receipt from the reproduced fixture, not a provider quote:
samples=300 interval_seconds=300 discarded_each_direction=15
p95_in_mbps=23.70 p95_out_mbps=41.60
billable_separate_then_max_mbps=41.60
alternative_per_interval_max_mbps=154.00
average_total_mbps=70.50 observed_transfer_gb=793.181
p95_if_sustained_30d_tb=13.478
commit_mbps=50.00 overage_mbps=0.00 overage_cost=0.00
discarded_in_range=180.00..194.00
discarded_out_range=140.00..154.00
ConnetU’s worked directional billing example calculates inbound and outbound percentiles separately and then chooses the larger result. The lab labels that method separate_then_max. Some contracts may instead choose the larger direction inside each interval before ranking, represented by per_interval_max.
| Rule applied to the same 300 rows | Result | What caused it |
|---|---|---|
| Separate inbound and outbound tails, then choose larger survivor | 41.60 Mbps | Each direction discards its own 15 burst intervals |
| Choose each interval’s larger direction, then discard one combined tail | 154.00 Mbps | Thirty non-overlapping burst intervals compete for only 15 discarded positions |
Neither label should be guessed from a graph. Ask the provider to state the direction function and reproduce it on a short known dataset. A 112.40 Mbps difference here comes from policy, not measurement noise.
Port capacity remains separate from both results. Place the uplink beside CPU, memory, storage, redundancy, and management terms in dedicated server hardware inventory. A 1 Gbps port can carry a 154 Mbps interval; it does not say whether the bill includes 50 Mbps, 100 Mbps, 1 Gbps, or a transfer quota.
Run the same accepted ledger with a lower declared commit. Only the commercial inputs change; the traffic sample and percentile stay fixed.
python3 "$LAB/calculate_p95.py" "$LAB/traffic.csv" \
--expected-samples 300 \
--interval-seconds 300 \
--commit-mbps 35 \
--overage-per-mbps 7.50 | tail -n 1
The result is commit_mbps=35.00 overage_mbps=6.60 overage_cost=49.50. That amount is only illustrative because currency, tiers, rounding, minimum charges, pooled commits, and taxes are contract-specific. The useful decision is whether ordinary busy periods repeatedly survive above the commit, not whether the largest spike looks dramatic.
Average total traffic was 70.50 Mbps across both directions in this fixture, while the separate-direction billed result was 41.60 Mbps. They are not contradictory: the average sums two directions, while this billing rule chooses one directional survivor. The reported 13.478 TB is merely the decimal transfer that 41.60 Mbps would move if sustained continuously for 30 days. Actual observed transfer was 793.181 GB across the 25-hour fixture. A percentile alone cannot reconstruct a unique monthly TB total.
Carry recurring commit fees, overage scenarios, monitoring, management, and recovery ownership into 12-month hosting budget rather than comparing the port label alone. A lower commit is attractive only if the expected overage and operational response remain acceptable.
Data quality belongs in that decision. Missing rows can reduce the number of high samples, change the discarded count, or hide a billing outage. The audit calculator refuses an incomplete export instead of silently sorting what remains.
python3 - "$LAB/traffic.csv" "$LAB/missing-row.csv" <<'PY'
import pathlib
import sys
source = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8").splitlines()
pathlib.Path(sys.argv[2]).write_text(
"\n".join(source[:121] + source[122:]) + "\n",
encoding="utf-8",
)
PY
if python3 "$LAB/calculate_p95.py" "$LAB/missing-row.csv" \
--expected-samples 300 --commit-mbps 50; then
printf 'Unexpectedly accepted missing row\n' >&2
exit 1
else
printf 'missing-row control rejected as intended\n'
fi
Duplicated timestamps are also rejected. A repeated row can overweight one interval and conceal a missing neighbor even when the total count still equals 300.
python3 - "$LAB/traffic.csv" "$LAB/duplicate-time.csv" <<'PY'
import csv
import sys
with open(sys.argv[1], newline="", encoding="utf-8") as source:
rows = list(csv.DictReader(source))
rows[80]["timestamp_utc"] = rows[79]["timestamp_utc"]
with open(sys.argv[2], "w", newline="", encoding="utf-8") as target:
writer = csv.DictWriter(
target,
fieldnames=["timestamp_utc", "in_mbps", "out_mbps"],
)
writer.writeheader()
writer.writerows(rows)
PY
if python3 "$LAB/calculate_p95.py" "$LAB/duplicate-time.csv" \
--expected-samples 300 --commit-mbps 50; then
printf 'Unexpectedly accepted duplicate timestamp\n' >&2
exit 1
else
printf 'duplicate-time control rejected as intended\n'
fi
The audit is complete when the export has the declared row count, every adjacent timestamp is exactly one sampling interval apart, all rates are finite and non-negative, the discarded count and highest survivors can be reproduced, the direction rule matches the contract, and the commit calculation matches the retained receipt. In the worked fixture, the two negative controls must also fail with expected 300 samples, found 299 and duplicate timestamp.
Send the provider a compact request with the sample calculation attached:
Attack treatment deserves written scope. Coordinate billing evidence with DDoS protection operations because the meter may sit before or after scrubbing. Do not assume malicious traffic is excluded merely because mitigation kept the application online.
Kentik’s network cost monitoring guide sets a useful operational goal: reproduce the provider’s bill continuously, alert before the running percentile crosses the commit, and reconcile the final invoice. A small team can start with the retained CSV and script; a larger network may need aggregation, contract inventory, flow attribution, and access controls around billing telemetry.
No. Average bandwidth sums the sampled rates and divides by the sample count. A provider-style 95th-percentile method ranks samples, removes a defined high tail, and keeps the highest survivor. The two measures answer different capacity questions and can differ sharply on bursty traffic.
Multiply the accepted sample count by 5%, then apply the contract’s rounding rule. A 30-day window with 8,640 complete five-minute samples has exactly 432 rows in 5%. Months, partial cycles, missing data, and other intervals can change the count, so record the provider’s rule rather than assuming 432 every time.
Contracts differ. One may calculate both directions separately and bill the larger survivor; another may select the larger direction per interval, sum directions, or bill only outbound traffic. Ask for the exact function because non-overlapping directional bursts can produce materially different results.
No unique conversion exists. Mbps is a rate statistic and TB is transferred volume. Multiplying the percentile rate by every second in a month gives a sustained-rate equivalent, not the actual bytes moved, because the underlying intervals vary and the highest 5% were removed from the billing statistic.
Not necessarily. Port speed is the physical or configured ceiling, and that label alone does not prove the included commit. A contract may include a lower or full-rate commit, allow bursts, or use a transfer quota instead. Confirm port capacity, commit, transfer quota, overage, throttling, and fair-use terms as separate fields.
The buyer-side audit should stop and identify the gap. The provider contract should explain whether missing intervals are estimated, ignored, carried forward, marked unavailable, or resolved through another rule. Duplicates need removal only under an agreed identity check; silently sorting either defect can change the bill.
A defensible bandwidth receipt contains the contract version, meter/interface, UTC window, interval, expected and accepted row counts, direction rule, discarded-count rule, inbound and outbound survivors, chosen billable rate, commit, overage terms, exceptions, script hash, and raw-export hash. Without those fields, 41.60 Mbps is a number without ownership.
Retain the calculator and receipt when they belong to an authorized invoice audit. Remove only the disposable fixture after checking its marker and exact path:
test "$(<"$MARKER")" = 'voxfor-bandwidth-p95-lab-v1'
case "$LAB" in
/tmp/voxfor-bandwidth-p95-lab) ;;
*) printf 'Refusing unexpected cleanup path\n' >&2; exit 1 ;;
esac
rm -rf -- "$LAB"
test ! -e "$LAB"
printf 'cleanup=lab_path_absent\n'
The cleanup command is appropriate only for the exact marker-bound lab path. It is not a production rollback. If a billing conclusion was already used for a purchase or commit change, preserve the original contract, export, script, receipt, invoice, and approval record; reverse the commercial change through the provider’s documented process rather than deleting evidence or rewriting historical samples.