The Magic Numbers Antipattern: Name the Decision, Not the Value

By Sergey Nosov

16 October 2026

What does the 18 in this line mean?

if (user.Age >= 18)

It might be the voting age, the age of legal adulthood in some jurisdiction, or a store’s rule about who may place an order. The code works, but its meaning lives only in the head of whoever wrote it. The next reader has to guess, ask, or dig through documentation.

That 18 is a magic number, and the cure is old. Martin Fowler’s Refactoring cataloged it in 1999 as Replace Magic Number with Symbolic Constant, and the 2018 second edition calls it Replace Magic Literal: give the number a name.

This article argues that the name does more than relabel. It turns the number into a claim about the code, and a reviewer can check a claim. The two examples below are a shipping rule in C# and a retry helper in TypeScript. In both, the names exposed what the code had done all along: a $63.75 jump in a shipping price and a helper that gave up at the first timeout. One name was itself wrong: a constant named for retries that counts attempts.

What Makes a Number Magic

Brian Kernighan and Rob Pike define the term broadly in The Practice of Programming (1999). Their definition reads: “Magic numbers are the constants, array sizes, character positions, conversion factors, and other literal numeric values that appear in programs.” Steve McConnell’s Code Complete (second edition, 2004) has the words that matter, describing literals “that appear in the middle of a program without explanation.” The same problem comes as strings, which McConnell calls magic strings, and as a bare true or false argument whose meaning only the function’s signature knows.

Whether a literal is magic depends on its reader. A 0 used as the first index of an array explains itself. The 86400 in a time calculation is magic to anyone who does not recognize the number of seconds in a day. A 200 in an HTTP handler is plain to a web developer and opaque to most other people. The test is whether the next competent reader will know, without asking, what the value means.

The Jargon File, a glossary of hacker slang, lists four senses of the term. Ours comes first: a non-obvious constant “inserted inconspicuously in-line (hardcoded).” The third is a value at the start of a file that tells a utility what kind of file it is. Early Unix executables began with one, such as octal 0407, which a PDP-11 read as an instruction to branch past the header. Every Java class file still begins with 0xCAFEBABE. A number like that is meant to be recognized, not understood, yet the code that checks it should give it a name.

What a Bare Number Costs

A bare number costs in five ways.

A good name fixes the first four. Testability takes more: the code has to receive the value instead of declaring it, which the section on where numbers live covers. Of the four, reviewability matters most, and the two examples that follow show why.

A Shipping Rule in C#

Here is a method that calculates the cost of shipping an order:

public decimal CalculateShipping(Order order)
{
    if (order.Total > 100)
    {
        return 0;
    }

    if (order.Weight > 50)
    {
        return order.Weight * 1.25m + 9.99m;
    }

    return 9.99m;
}

What do 100, 50, 1.25, and 9.99 mean? A new engineer would have to ask what the threshold for free shipping is, what weight counts as heavy, what the surcharge per pound is, and what the standard fee is. Here is the same logic with named constants:

private const decimal FreeShippingThreshold = 100m;
private const decimal HeavyOrderWeightLimitLbs = 50m;
private const decimal PerPoundSurchargeRate = 1.25m;
private const decimal StandardShippingFee = 9.99m;

public decimal CalculateShipping(Order order)
{
    if (order.Total > FreeShippingThreshold)
    {
        return 0;
    }

    if (order.Weight > HeavyOrderWeightLimitLbs)
    {
        return order.Weight * PerPoundSurchargeRate + StandardShippingFee;
    }

    return StandardShippingFee;
}

The method body now reads almost like the business rule: free shipping above a threshold, a surcharge for heavy orders, and the standard fee otherwise. The numbers did not change; they gained names. If the standard fee moves to 10.99, the change happens in one place instead of two, and the diff says what changed.

The names also made the code checkable. Each one states what its number is for, and a reader who knows the business can hold those statements against the code. Three of them raise questions.

The surcharge applies to every pound. A rate called a per-pound surcharge, next to a weight limit, can suggest a charge on the pounds over the limit. The formula multiplies the whole weight, including the first 50 pounds. I ran both versions, and they charge the same. Below the free-shipping threshold, a 50-pound order ships for $9.99, and a 51-pound order ships for $73.74, so one more pound costs $63.75.

That may be the rule. It may also be a bug that a customer finds first. With literals, a reviewer could not tell what 1.25 was meant to be, so there was nothing to hold the formula against. With names, the question is hard to miss, and if the rule is a surcharge on the pounds over the limit, the fix is small. The 51-pound order then ships for $11.24:

if (order.Weight > HeavyOrderWeightLimitLbs)
{
    var poundsOver = order.Weight - HeavyOrderWeightLimitLbs;
    return StandardShippingFee + poundsOver * PerPoundSurchargeRate;
}

The unit is a promise that nothing enforces. The suffix of HeavyOrderWeightLimitLbs says pounds. order.Weight says nothing, and the compiler cannot tell pounds from kilograms. If some other code fills it in kilograms, the limit and the surcharge are both wrong, and everything still compiles. Put the unit where callers can see it: rename the property to WeightLbs, or give weight a type of its own.

The threshold does not say which side it is on. An order of exactly $100.00 does not ship free, because the code compares with >. Is shipping free for orders over $100, or for orders of $100 or more? FreeShippingThreshold does not say, and neither did the literal. Decide, and pin the answer with a test at exactly 100.00.

The fourth number, the standard fee, raises a different question: where it should live. That question comes after the second example.

A Retry Helper in TypeScript

The second example is a small helper that fetches a URL and retries after a server error:

async function fetchWithRetry(url: string): Promise<Response> {
  for (let i = 0; i < 3; i++) {
    const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
    if (response.status < 500) {
      return response;
    }
    await new Promise(r => setTimeout(r, 250 * Math.pow(2, i)));
  }
  throw new Error("Request failed");
}

Why 3? Why 5000? Why 250? Why 500? An experienced engineer can guess most of these, but the code does not commit to anything. Here is the same function with its decisions promoted to named constants:

const MAX_RETRY_ATTEMPTS = 3;
const REQUEST_TIMEOUT_MS = 5_000;
const INITIAL_BACKOFF_MS = 250;
const SERVER_ERROR_STATUS_FLOOR = 500;

async function fetchWithRetry(url: string): Promise<Response> {
  for (let attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) {
    const response = await fetch(url, {
      signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
    });
    if (response.status < SERVER_ERROR_STATUS_FLOOR) {
      return response;
    }
    const backoff = INITIAL_BACKOFF_MS * Math.pow(2, attempt);
    await new Promise(r => setTimeout(r, backoff));
  }
  throw new Error("Request failed");
}

Now the loop reads like English. It retries up to a maximum number of attempts, gives each request a timeout, treats any status at or above the server-error floor as retryable, and backs off exponentially from a known initial delay. The logic did not grow, and every decision is visible and tunable. Visible decisions can be checked, so I ran both versions on Node.js 24 against a local server that answered with chosen status codes or not at all. I found four problems.

MAX_RETRY_ATTEMPTS counts attempts, not retries. The loop sends three requests in all: the first try and two retries. The name says three retries, and a reader who knows Polly, a .NET library for handling transient faults, would expect four requests. Polly documents its MaxRetryAttempts option, which defaults to 3, as “The maximum number of retry attempts to use, in addition to the original call.” MAX_ATTEMPTS says what the loop does.

The timeout ends the call. REQUEST_TIMEOUT_MS invites two questions: does it bound one request or the whole call, and what happens when it fires? It bounds each attempt, so the whole call can take 15 seconds, plus the waits. When it fires, the signal fails the fetch, and MDN documents how: “The signal aborts with a TimeoutError DOMException on timeout.” Nothing in the loop catches that error.

A server that hung on the first request made the helper throw after 5 seconds, although the second attempt would have succeeded. For a GET, a timeout is among the failures a retry helper exists to absorb. The reviewed version catches the error and names the scope: ATTEMPT_TIMEOUT_MS.

The floor names a number, not a decision. SERVER_ERROR_STATUS_FLOOR answers the question “What is 500?” The decision behind it is a different question: which responses deserve another attempt? Two such responses sit below the floor. RFC 6585 defines 429 Too Many Requests for rate limiting and lets the server say how long to wait. RFC 9110 says of 408 Request Timeout, “If the client has an outstanding request in transit, it MAY repeat that request.” The helper returned both to its caller as final answers.

Microsoft’s resilience library for .NET retries 408, 429, and every status from 500 up, according to its documentation. The reviewed version retries the same statuses and names the rule, as a function called isRetryable, instead of naming one number.

The backoff waits after the last attempt. Read with names, the loop says: try, back off, try again. After the third failure, it still backs off, for a full second, and only then throws. Three 503 answers took about 1.8 seconds, and the last second of that bought nothing. The reviewed version waits only between attempts. It also adds jitter, a random wait below a ceiling that doubles each time, so that clients that failed together do not retry in step.

Here is the reviewed version:

const MAX_ATTEMPTS = 3;
const ATTEMPT_TIMEOUT_MS = 5_000;
const FIRST_BACKOFF_CEILING_MS = 250;

// 408: Request Timeout
// 429: Too Many Requests
// 5xx: server errors
function isRetryable(status: number): boolean {
  return status === 408 || status === 429 || status >= 500;
}

const sleep = (ms: number) =>
  new Promise((resolve) => setTimeout(resolve, ms));

async function fetchWithRetry(url: string): Promise<Response> {
  let ceilingMs = FIRST_BACKOFF_CEILING_MS;
  for (let attempt = 1; ; attempt++) {
    let failure: unknown;
    try {
      const response = await fetch(url, {
        signal: AbortSignal.timeout(ATTEMPT_TIMEOUT_MS),
      });
      if (!isRetryable(response.status)) {
        return response;
      }
      // Free the connection first.
      await response.body?.cancel();
      failure = new Error(`HTTP ${response.status}`);
    } catch (error) {
      // Timeout or other failure.
      failure = error;
    }
    if (attempt === MAX_ATTEMPTS) {
      throw new Error("Request failed", { cause: failure });
    }
    // Full jitter: a random wait
    // below a doubling ceiling.
    await sleep(Math.random() * ceilingMs);
    ceilingMs *= 2;
  }
}

Against the local server, the reviewed version recovered from a hung first request and from a 429, each on the second attempt.

Two smaller changes came along. The helper cancels the body of every response it discards. Node.js builds its fetch on undici, whose README warns that unread bodies left for the garbage collector can tie up connections and even cause “stalls or deadlocks.” The final error also keeps the last failure as its cause.

The helper is still a sketch. It sends only GET requests, which HTTP defines as idempotent. Retrying a POST safely takes more, such as an idempotency key, which another article on this site describes. It ignores the Retry-After header that a 429 or 503 response may carry, and a production client should wait at least that long. It also retries failures that no retry will fix, such as a 501 Not Implemented, which Microsoft’s library retries too, or a malformed URL. Its timeout keeps running while the caller reads the body, and nothing retries a timeout there.

The problems with the timeout, the floor, and the backoff were already in the version with bare literals. The names made them visible, because each one said what its number was for, and the code could be read against it. MAX_RETRY_ATTEMPTS was different: the name itself was wrong.

A Name Is a Claim

A name can be wrong in a way that a literal cannot, and a wrong name can be worse than none, because readers trust it. Even a name that looks obviously right can carry a false claim.

SECONDS_PER_DAY = 86_400 is right for Unix time. The POSIX standard says that “each and every day shall be accounted for by exactly 86400 seconds.” It is wrong for a calendar day in a time zone with daylight saving time. On 8 March 2026, the day in New York lasted 23 hours, as the time zones article on this site describes. Code that adds SECONDS_PER_DAY to a timestamp to reach the same local time tomorrow is wrong twice a year, and the name makes the mistake harder to doubt.

So review a name the way you review code: read it back against what the code does. Every finding above began with that kind of reading. Does the loop make MAX_RETRY_ATTEMPTS retries? Is order.Weight in pounds? Does every day have 86,400 seconds on the clock this code reads?

Where a Named Number Should Live

A name says what a number means. Where the name lives depends on why the number might change.

A fact that does not change belongs in a constant, or better, in a name the platform already provides. In .NET, 429 is HttpStatusCode.TooManyRequests. Microsoft’s documentation for the const keyword sets the limit: “Don’t create a constant to represent information that you expect to change over time,” such as “the price of a service.” The compiler copies a constant’s value into the code that uses it. When a library changes a public constant, “other code compiled with your libraries needs to be recompiled to see the changes.”

A design decision of the code itself, such as how many attempts to make, belongs in a private constant next to where it is used. It changes when the code changes, and it should be reviewed like code. One that must differ between environments, as a timeout may, belongs in configuration.

A business value that changes on its own schedule, such as a fee or a free-shipping threshold, belongs in configuration. A change there does not wait for a new build. The shipping rates become an options class:

public sealed class ShippingRates
{
    public decimal FreeShippingThreshold { get; set; }
    public decimal HeavyOrderWeightLimitLbs { get; set; }
    public decimal PerPoundSurchargeRate { get; set; }
    public decimal StandardShippingFee { get; set; }
}

The class that calculates shipping receives these rates instead of declaring them, so a test can supply its own. The names travel with the numbers, into the ShippingRates section of appsettings.json:

"ShippingRates": {
  "FreeShippingThreshold": 100,
  "HeavyOrderWeightLimitLbs": 50,
  "PerPoundSurchargeRate": 1.25,
  "StandardShippingFee": 9.99
}

Configuration has a cost that constants do not: a value in a JSON file skips the compiler and can skip the code review. Validate it when the application starts. In .NET, the options pattern does that in one chain:

builder.Services.AddOptions<ShippingRates>()
    .BindConfiguration("ShippingRates")
    .Validate(rates => rates.FreeShippingThreshold > 0
            && rates.HeavyOrderWeightLimitLbs > 0
            && rates.PerPoundSurchargeRate > 0
            && rates.StandardShippingFee > 0,
        "Every shipping rate must be positive.")
    .ValidateOnStart();

Without ValidateOnStart, validation waits until the application first reads the options. I tested what the check prevents on .NET 10. With the key misspelled as StandardShipingFee, the binder ignored it and left the fee at zero. The application started without a warning, ready to ship every light order for free. With the validation in place, the same file stopped the application at startup with “Every shipping rate must be positive.” Setting the binder’s ErrorOnUnknownConfiguration option also rejects the misspelled key by name.

A unit belongs in a type, such as .NET’s TimeSpan or a weight type of your own. A TimeSpan cannot be a C# constant; the compiler rejects it with error CS0283. Declare it static readonly instead. A diff of SessionTimeout = TimeSpan.FromHours(2) then says two hours in words, where SessionTimeoutSeconds = 7200 still leaves the arithmetic to the reviewer. When an API takes a bare number, as AbortSignal.timeout() takes milliseconds, put the unit in the name, as in ATTEMPT_TIMEOUT_MS.

A set of related codes belongs in an enum. In C#, an enum names its values but does not restrict them. A cast or Enum.TryParse accepts a number that has no name, so check values where they enter the system, as the Strategy article on this site shows.

In TypeScript, an enum is one of the constructs that Node.js refuses to run when it strips types, and TypeScript’s --erasableSyntaxOnly flag, added in version 5.8, rejects it. The TypeScript handbook itself says, “In modern TypeScript, you may not need an enum when an object with as const could suffice.” A function that takes this Priority type rejects a 4 at compile time:

const Priority = {
  Low: 1,
  Normal: 2,
  High: 3,
} as const;

type Priority = (typeof Priority)[keyof typeof Priority];

When a Literal Is Fine

Not every number needs a name. These usually do not:

The classic sources and tools draw the line in different places. Kernighan and Pike suggest that “any number other than 0 or 1 is likely to be magic and should have a name of its own.” The Java linter Checkstyle exempts −1, 0, 1, and 2 by default, and SonarSource’s rule for C# exempts −1, 0, and 1. The line is a judgment about the next reader, not a rule about digits. When in doubt, give the number a name. A good name costs almost nothing and pays back on every read.

Make the Question Routine

In code review, treat an unexplained literal as a question: what does this number mean? I would rank that one question, asked routinely, among the highest-leverage things a reviewer can do. Let a linter ask it first.

ESLint’s no-magic-numbers rule accepts a number that initializes a declared variable or, by default, an object property, because there the number has a name. It reports most other numeric literals, even 0 and 1, unless they are on its ignore list, which is empty by default.

I ran ESLint 10.12 with only that rule on the three versions of the retry helper. On the original, it reported five numbers: 3, 5000, 500, 250, and 2. On the version with named constants, it reported one, the 2 in Math.pow(2, attempt). On the reviewed version, it reported four: the three status codes and the 2 that doubles the backoff. Those four are the judgment calls a rule cannot make. A team that keeps such a number can give the reason in an ESLint disable comment, after two dashes, where the next reviewer will see it.

For C#, SonarSource’s analyzer has the equivalent rule, S109, which its default profile leaves off; the rule description lists its exemptions. With the rule turned on, it flagged five literals in the shipping method before naming (100, 50, 1.25, and 9.99 twice) and none after. Neither tool noticed any of the problems that reading the names turned up. A linter can check that a number has a name. Only a reader can check that the name is true.

A Checklist

The antipattern has its own entry in my Software Development Principles series, with indicators of violation and of proper application. The short version of this article fits in a sentence: a name that states a decision is a claim that a reviewer can check.

Further Reading