← Back to Academy
Engineering

Your week-over-week number is lying

· GatiFlow Academy

Here is a week of npm downloads, compared with the week before it, the way almost every dashboard compares them: sum the seven days, sum the previous seven, divide.

19-25 Oct  ->  26 Oct-1 Nov

@sentry/node
  27,015,602 -> 54,287,476  +100.9%
rollbar
     309,029 ->    709,337  +129.5%
@sanity/client
   2,227,716 ->  4,586,213  +105.9%

An error tracker, a second error tracker and a CMS client, all doubling in the same seven days. If you were watching all three, that would be a market signal worth writing up. I read those figures on 3 November from npm's public API. On the days the two weeks actually have in common, the same three packages moved -1.9%, -0.5% and +3.3%. Nothing doubled. Nothing really moved.

The tell is in the table, and it is the thing a dashboard shows you one row at a time: everything moved together. Unrelated packages with unrelated users do not double in the same week. When every series moves at once, the thing that moved is the source.

What the API actually returned

npm's downloads endpoint takes a date range and a package and answers one figure per day:

https://api.npmjs.org/downloads/range/2026-10-12:2026-11-02/rollbar

For a day it has not counted, it does not leave the day out and it does not return an error. It returns the day, with downloads: 0.

Here is the earlier of the two weeks for @sentry/node, Monday first, as published on 3 November:

2026-10-19  Mon          0
2026-10-20  Tue          0
2026-10-21  Wed          0
2026-10-22  Thu  9,397,495
2026-10-23  Fri  8,656,444
2026-10-24  Sat  4,550,452
2026-10-25  Sun  4,411,211

A package doing nine million downloads on a Thursday did not do zero on the Wednesday. That week is four days long, and it is being compared with a week that is seven days long. The doubling is the three missing days, nothing else.

The zeros are not per package. The four npm packages I checked that day, the three above and resend, came back with zeros on exactly the same six days: 13 to 15 October and 19 to 21 October. A gap in a source's counting pipeline lands on every series it serves, which is why the symptom is a whole column moving rather than one strange row.

A late day and a lost day look identical

Two days earlier, on 1 November, resend had zeros in three places: 13 to 15 October, 19 to 21 October, and 30 to 31 October. By 3 November the last two had been filled in with ordinary figures. The other six were still zeros, two to three weeks after the days they stand for. So a zero from this API can mean not counted yet, or never will be, and one read cannot tell you which.

This is the part that generalizes to any upstream you consume. If a source can report a value it does not have yet as an ordinary-looking value, every number you derive from it is only as good as your ability to tell the two apart, and zero is the worst case, because zero is a plausible number for almost anything you count. A weekly total is a claim about the source before it is a claim about the thing you are counting.

I had already fixed this three times

This was the fourth shape of the same bug in the GatiFlow report. Each earlier fix was right about one thing and wrong about the next:

August: yesterday is not finished. The same window, read twice on one day, gave @anthropic-ai/sdk -16.7% at 01:55 UTC and +0.3% at 19:04. The window now ends two days back.

September: a published zero is not a quiet day. Summed as published, @anthropic-ai/claude-code read -22.6%; on the days that had figures, -7.1%. Zeros became gaps, and the weeks were compared as averages per observed day — a rule my own comment called one which no gap can distort.

Two weeks later: an average over different days is not a comparison. Downloads have a weekly cycle. npm lost a Tuesday and a Thursday, the average kept the weekend in, and it said +14.3% where the same days of the week said +6.8%. The weeks are now compared weekday against weekday.

Each fix was tested, and each test passed, because each test reproduced the case I had just seen. None of them asked what the next gap would look like. This round broke two more of my beliefs. On 1 November the last day of the window, 30 October, was itself a zero, so two days back had not been enough. And the September comment in the collector says npm answers 0 for a day it missed and keeps answering 0. Two days later it had filled 30 and 31 October in. The worse part is that this was not news: my own engine notes had recorded npm counting 22 September late, back in September, and the comment above the code was never corrected until this article. My documentation of the upstream was wrong in exactly the direction that had made the previous fix look complete, and the correction sat one file away.

The rule that shipped

What the collector does now is short enough to say in four lines:

  1. A zero, or a missing day, is a gap. Not a value.
  2. The two weeks are compared only on the days of the week both of them have. Position one of the previous week against position one of the current week, and so on, and a pair with a gap on either side is dropped.
  3. Fewer than five such pairs is not a week. There is no percentage, and the report says so: no previous week to compare.
  4. When the current week itself has gaps, the total says how many days it is made of: downloads over 5 of the 7 days to … rather than in the week to ….

Applied to the table at the top, the rule prints no percentage for any of the three packages: the earlier week has four days with figures, and four matched days is below the line. The -1.9%, -0.5% and +3.3% at the top of this article are those four days. I computed them to write this, and the report would not print them. Four days of a seven-day cycle is not a week, and a threshold you relax when the answer looks reasonable is not a threshold.

The detector, and the test that reproduces the case

This is the minimum version, without the caching and the logging. It takes a dict of day to count, as you would build from almost any daily-metrics API, and the last day of the current week:

from datetime import date, timedelta

MIN_DAYS = 5  # fewer comparable days than this is not a week


def week_over_week(daily, end):
    # daily maps 'YYYY-MM-DD' to a count; end is the last day of the current week
    days = [end - timedelta(days=13 - i) for i in range(14)]
    values = [daily.get(d.isoformat()) or None for d in days]  # a 0 is a gap
    previous, current = values[:7], values[7:]
    # position i in each week is the same day of the week
    pairs = [(a, b) for a, b in zip(previous, current) if a and b]
    change = None
    if len(pairs) >= MIN_DAYS:
        before = sum(a for a, _ in pairs)
        after = sum(b for _, b in pairs)
        change = round((after - before) / before * 100, 1)
    return {
        'current_days': sum(1 for v in current if v),
        'previous_days': sum(1 for v in previous if v),
        'matched_days': len(pairs),
        'change_pct': change,
    }

Two details carry the weight. A published zero and a missing key end up as the same gap: or None says so where the data comes in, and every check after it treats a gap as empty, because from the consumer side they are the same thing — no figure for that day. And the comparison sums the matched pairs, not the weeks, so a gap on one side removes the same weekday from the other.

The test is the case from the top of this article, with the real figures, so it fails the day someone simplifies the function back into two sums:

def test_lagging_days_are_not_growth():
    # @sentry/node on npm, read 2026-11-03; Monday 19 October first
    previous = [0, 0, 0, 9397495, 8656444, 4550452, 4411211]
    current = [9037091, 9259824, 9498735, 9317523, 8158484, 4369876, 4645943]
    start = date(2026, 10, 19)
    daily = {
        (start + timedelta(days=i)).isoformat(): n
        for i, n in enumerate(previous + current)
    }
    naive = (sum(current) - sum(previous)) / sum(previous) * 100
    assert round(naive, 1) == 100.9  # what a plain comparison prints

    result = week_over_week(daily, end=date(2026, 11, 1))
    assert result['previous_days'] == 4
    assert result['matched_days'] == 4
    assert result['change_pct'] is None  # what is actually known

The first assertion matters as much as the last. It pins the wrong answer next to the right one, so a reader of the test sees the size of what is being refused, and so the fixture cannot quietly drift into a case where the naive comparison was harmless anyway. Every Python block on this page runs in this site's own test suite, against this page's text.

Ask the other series

The detector above has one blind spot, and my collector had the same one: it decides what a zero means by looking at one series. One series cannot tell a lost day from a quiet one. Several can, and the clue that started this article — everything moving together — is exactly that information, written down as code:

def source_gaps(reads, min_series=3):
    # reads: series name -> {'YYYY-MM-DD': count}, all from one read
    if len(reads) < min_series:
        return []  # too few series to blame the source
    days = set().union(*reads.values())
    return sorted(
        d for d in days if not any(r.get(d) for r in reads.values())
    )

A day with no figure for every unrelated series read from the same source, in the same read, belongs to the source. A zero in one series while the others have figures is far more likely to be that series being quiet. The test uses two real rows from this window and one small package with a genuinely empty Thursday:

def test_a_day_missing_everywhere_is_the_sources():
    reads = {
        '@sentry/node': {'2026-10-21': 0, '2026-10-22': 9397495},
        'rollbar': {'2026-10-21': 0, '2026-10-22': 133801},
        'a-small-package': {'2026-10-21': 0, '2026-10-22': 0},
    }
    # 21 October: nobody has a figure, so npm lost the day
    # 22 October: only the small package is empty, so it was quiet
    assert source_gaps(reads) == ['2026-10-21']

Three series is the floor I would use, and they should not share users: two packages from the same vendor can go quiet on the same day for reasons that have nothing to do with the source.

The other registry did not do this

The report reads PyPI through pypistats in the same cycle. Over the same window, 12 October to 2 November, sentry-sdk, rollbar and resend on PyPI had a figure on every one of the 22 days and no zeros at all.

That is not a reason to trust it more. It is a reason to stop treating upstream failure as one thing. pypistats had its own shape: it publishes a new dated row partway through the day, and the collector used to take the last fourteen rows it returned rather than fourteen named days, so the window slid between two reads of the same date — langchain went from 42,140,069 to 40,581,983 between 01:29 and 09:59 on 2 September. npm reports a gap as a zero. pypistats reported the calendar as a list length. Each source has its own way of being incomplete, and a guard written for one says nothing about the other.

What is still open

When this article first went up, the collector did not do that cross-series check. It read a zero as a gap one package at a time, which is safe for the packages it was written for, doing six and seven figures a day, and wrong for a small package that genuinely has days with no downloads: it read real silence as missing data, and declined to compare weeks that were complete. Saying so in public was enough to fix it the same day. The measurement of the packages GatiFlow subscribers follow now reads every npm answer of a run before it measures any of them, and runs the check above across them, with three packages as the floor. The fixed list of large packages keeps the old rule, because none of them has a quiet day.

And a report printed while days were missing keeps what it printed. The rule declines to claim; when the upstream fills the days in later, the next window gets the figures, but the report that already went out said no previous week to compare and stays that way. Declining is safe. It is not the same as being right late.

Seven things to check in your own comparisons

  1. Pull one week from your upstream and look at the raw daily values, not the chart. A zero next to a large number is a gap until proven otherwise.
  2. Find out what your source returns for a day it has not counted: an absent key, a null, an error, or a zero. The last one is the dangerous one, and nothing in a response schema will tell you.
  3. Read the same window twice, a few hours or days apart. If the totals differ, your window ends on a day that is not finished.
  4. Count the days behind each total, and carry that count with the number. A total without it is a claim about the source as much as about the thing measured.
  5. Compare the same days of the week, never two averages over different days. Anything with a weekly cycle will make the average lie for you.
  6. Set a minimum of comparable days and print nothing below it. A dashboard that says no comparison is available is more useful than one that says +100%.
  7. The ten-second one: when every series in a batch moves the same way in the same week, check the source before you check the market.

The first article I wrote here argued that API documentation is a second implementation, written once and never compared with the system again. The second was about a retry loop that had never run under the conditions it existed for. This one is the same pattern from the consumer side. My comment in the collector was a model of npm, written from the failure I had just seen, and npm moved past it. The data you get from an API is a claim about its own completeness as much as about anything else, and the only way to check that claim is to count the days.

Free 5-day course

Trend Reading 101

Five lessons by email, one per day, on how technology signals are measured: what an indicator can and cannot represent, how a composite score is built, and what a report does not say. Each lesson states its objectives, works through a documented case, cites its sources, and ends with an exercise.

We respect your inbox. Lessons only — no spam, no third-party sharing. What we store, and for how long, is in the privacy policy.

Tell me I am wrong

Corrections, the version of this you have lived through, or what you would like covered next. It reaches me directly and is never published. It is kept for two years so it can be read and answered; the privacy policy has the details.

0/2000


The public API described here is documented at /api-docs, and the scoring behind it at /methodology.