Choosing Log vs Symlog Scales for Skewed Data

Your data spans four orders of magnitude, so you reach for scaleLog, and the axis renders blank or throws — because the values include zero, or dip negative, and a logarithm has no answer there.

This guide belongs to D3 scales and axes configuration, and it resolves one specific fork in the scale-selection decision: heavily skewed data that a linear scale flattens but that scaleLog cannot represent because its domain must be strictly positive. The answer is scaleSymlog, a symmetric-log scale that is linear near zero and logarithmic in the tails, so it handles zero-crossing and negative data that scaleLog refuses.

Diagnostic checklist

How linear, log, and symlog map a skewed zero-crossing domain Linear collapses small values, log cannot represent zero or negatives, and symlog stays linear near zero while compressing both tails logarithmically. scaleLinear small values pile up at 0 scaleLog 0 and negatives → NaN scaleSymlog linear near 0, log in tails 0 −1000 +1000 linear band log-compressed log-compressed symlog: a linear window around zero flanked by logarithmic tails
Linear buries small magnitudes, log rejects zero and negatives, and symlog keeps a tunable linear window around zero with logarithmic compression in both tails.
Linear, log and sqrt scale mappings The same input domain mapped to pixels three ways: linear, logarithmic and square-root. input (domain)pixels (range)linearlogsqrt
How linear, log and sqrt scales each map one domain onto the pixel range — the choice at the heart of log vs symlog scales for skewed data.

Broken vs fixed

// ❌ BROKEN: scaleLog over a domain that includes zero and negatives.
import { scaleLog } from 'd3-scale';

const values: number[] = [-450, -3, 0, 12, 8800];
const y = scaleLog()
  .domain([Math.min(...values), Math.max(...values)])  // [-450, 8800]
  .range([300, 0]);

y(0);     // → NaN: log(0) is -Infinity
y(-3);    // → NaN: log of a negative is undefined
// The axis renders blank or partial; hit-testing via y.invert breaks across 0.
// ✅ FIXED: scaleSymlog handles zero-crossing and negative data.
import { scaleSymlog } from 'd3-scale';
import { axisLeft, Axis } from 'd3-axis';
import { format } from 'd3-format';

const values: number[] = [-450, -3, 0, 12, 8800];
const y = scaleSymlog()
  .domain([Math.min(...values), Math.max(...values)])  // [-450, 8800]
  .range([300, 0])
  .constant(10);          // width of the linear region around zero (default 1)

y(0);     // → a finite pixel; 0 sits inside the linear band
y(-3);    // → finite; negatives map symmetrically to positives

// A11Y: SI-prefix ticks stay readable across four orders of magnitude.
const fmt = format('~s');                        // 8800 → "8.8k", -450 → "-450"
const axis: Axis<number> = axisLeft<number>(y).ticks(6).tickFormat((d) => fmt(+d));

The critical property is that scaleSymlog is symmetric about zero: it applies the same logarithmic compression to negatives as to positives by transforming sign(x) * log(1 + |x / constant|). Because that transform is defined at x = 0 (it returns 0) and for negative x (the sign factor mirrors it), the whole real line maps cleanly, and invert works across zero — which is what makes symlog safe for a brush or zoom that crosses the origin.

Step-by-step fix

import { scaleSymlog } from 'd3-scale';

// Choosing the constant: it is the half-width of the linear region in DATA units.
// Set it near the noise floor so values below it are treated linearly, not log-compressed.
function symlogFor(domain: [number, number], range: [number, number], noiseFloor: number) {
  return scaleSymlog()
    .domain(domain)
    .range(range)
    .constant(Math.max(1, noiseFloor));   // never below 1; tune to the data's small end
}
Drag the input and watch the same value land at different positions under linear, log, and square-root mappings. The log track shows exactly why zero and negative inputs have no place on a pure logarithmic axis — the very gap that scaleSymlog fills by holding a linear window around the origin.

Verification

import { scaleSymlog } from 'd3-scale';

const y = scaleSymlog().domain([-1000, 1000]).range([300, 0]).constant(10);
// Zero maps to a finite midpoint, and the scale is symmetric about it.
console.assert(Number.isFinite(y(0)), '0 must map to a finite pixel under symlog');
const mid = y(0);
console.assert(
  Math.abs((mid - y(100)) - (y(-100) - mid)) < 1e-6,
  'symlog must be symmetric: +100 and -100 equidistant from 0',
);
console.assert(Number.isFinite(y.invert(150)), 'invert must be finite across zero');

In DevTools, render the axis and confirm a zero tick appears and negative ticks are labeled, then log y(0), y(-3), and y(8800) and verify all three are finite pixels inside the range. Switching .constant() from 1 to 20 should visibly widen the flat band around the origin.

When log still wins

Symlog is not a universal replacement for log — it is the right tool only when the domain actually crosses or touches zero. For strictly positive data spanning orders of magnitude (response times, populations, prices), scaleLog remains the better choice because its ticks fall on clean powers of ten and readers fluent in log axes expect that spacing. Symlog’s linear-near-zero window, so useful for zero-crossing data, is a distortion you do not want when zero is not in range: it bends the low end of a positive-only axis away from true logarithmic spacing for no benefit. The decision reduces to a single question about the domain. If every value is strictly positive, use scaleLog. If the domain includes zero or goes negative and you still need logarithmic compression of the tails, use scaleSymlog. If the data is only mildly skewed, neither is warranted and a plain linear scale — or scalePow with a tuned exponent for a gentler curve — communicates more honestly than forcing a log transform.

Tick formatting across magnitudes

The second half of making a skewed axis usable is labeling. Automatic log and symlog ticks tend to emit bare powers of ten or awkwardly precise decimals, and on a symlog axis the tick generator can skip the near-zero region entirely. Two d3-format specifiers cover most cases: format('~s') gives SI prefixes (8.8k, 1.2M) that stay compact across orders of magnitude, and format('.2~r') gives rounded significant digits when SI prefixes read oddly for your audience. When the generated ticks are unsatisfactory, take control with .tickValues() and pass an explicit array that always includes 0 and a symmetric set of magnitudes such as [-1000, -100, -10, 0, 10, 100, 1000]; this guarantees the zero mark and matching positive and negative anchors that make the symmetry legible. Keep the label contrast at 4.5:1 against the plot background so the small end of the axis, which is where skewed data most needs precise reading, stays legible.

Edge cases & gotchas

  • scaleLog domain touching zero. Even a single 0 in the domain poisons the whole log axis with NaN; symlog or a small positive floor (Math.max(value, epsilon)) is required.
  • Constant too small. A .constant() far below your data’s noise floor compresses tiny values so hard they visually vanish; set it near the smallest magnitude you want to distinguish linearly.
  • Constant too large. An oversized constant turns most of the axis linear, defeating the log compression you wanted in the tails; tune it against the actual spread.
  • Sparse automatic ticks. Symlog’s tick generator may omit marks near zero; supply .tickValues() to guarantee a zero tick and symmetric anchors.
  • Perceptual honesty. Log and symlog axes exaggerate small differences at the compressed end; label the scale type prominently so readers do not misread compressed distances as linear ones.
  • Animating a domain change. Interpolating a symlog domain is non-linear near zero; capture source and target domains with scale.copy() and interpolate between snapshots rather than mutating in place mid-transition.

Frequently Asked Questions

Why does scaleLog break on data that includes zero?

Because the logarithm of zero is negative infinity and the logarithm of a negative number is undefined, so scaleLog returns NaN for those inputs and the axis renders blank or partial. A logarithmic scale’s domain must be strictly positive. For data that touches or crosses zero, use scaleSymlog, which stays linear in a window around zero and only compresses logarithmically in the tails.

What does scaleSymlog’s constant parameter do?

It sets the half-width, in data units, of the linear region around zero before logarithmic compression takes over. A larger constant widens that flat band so more small values are spaced linearly; a smaller constant narrows it so compression starts sooner. The default is 1, which is often too small for real data — set it near the smallest magnitude you want to read linearly.

Should I always use symlog instead of log to be safe?

No. Symlog is only the right choice when the domain crosses or touches zero. For strictly positive data spanning orders of magnitude, scaleLog gives cleaner power-of-ten ticks and the spacing readers expect, whereas symlog’s linear-near-zero window is an unnecessary distortion when zero is not in range. Pick symlog for zero-crossing data and log for positive-only data.