Build a Python Habit Streak Analyzer
Find the longest run of consecutive calendar dates in a list of habit check-ins.
Example check: Worked and guided code samples are checked against CPython 3.13. Practice uses Pyodide 0.29.5 (Python 3.13) in your browser. How we check lessons
Video lesson: Build a Python Habit Streak Analyzer
The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube
Jump to a chapter
Read the video transcript
Select a timestamp to watch that moment on YouTube.
- 00:00
Suppose a habit app exports these three dates: March first, February twenty- seventh, and February twenty-eighth. The events arrived out of order, but the actual calendar contains a three-day streak. Pause and predict what a simple loop over the file will print. Do not confuse the number of check-ins with the number of distinct days. We will make one small function with a precise contract: return the longest historical sequence of consecutive dates. Later, a product might also report the active streak ending today, but that is a different calculation that depends on a timezone and the definition of today. For this project, the list of date strings is all the input we need.
- 00:43
A habit log is a set of calendar days, not a sequence of button presses. A person can submit the same date twice and can import older entries after newer ones. To find a streak, parse each ISO date with date.fromisoformat, remove duplicates, sort the resulting date objects, and compare adjacent days with timedelta(days=1). The longest streak is the largest consecutive run anywhere in the history; it is different from a current streak ending today. This offline project needs no account, clock, or network call. Watch the first terminal run. The function parses each ISO string correctly but walks the list in the order
- 01:23
it was supplied. It sees March first followed by February twenty-seventh, which is a backwards jump. It resets the run. Then it sees February twenty-eighth, which is only a two-day run from the previous item. The reported best length is wrong for the actual calendar history. This is a useful example because the loop body looks plausible and works on neatly ordered hand-written data. The error is in an assumption about input order. Before changing the condition, we need to inspect the representation of the input and normalize it. The first implementation walks input order.
- 02:03
It reports a two-day streak for March first, February twenty-seventh, and February twenty-eighth because those entries are out of order. A string comparison can also hide assumptions about formatting. Print the parsed dates and their sorted order before changing the algorithm. Duplicates are another trap: two records for one date must not add a day to the streak or interrupt a valid run. Decide what the function means before trying to optimize it. The diagnosis prints both the raw strings and sorted date objects. It also includes February twenty-eighth twice. We can now see two independent facts: the file order is not chronological, and a
- 02:43
repeated check-in is not a new day. We convert first, because date objects make calendar operations explicit. We put them in a set to remove repeated dates, then sort the set so each comparison goes forward through time. Notice that a set itself has no useful chronological order; sorting is a separate step. This normalized list is the foundation for a reliable streak counter. Predict what the consecutive differences should be for the three remaining dates before the fixed run begins. After normalization, keep a current run length and a best run length. The first date starts a run of one.
- 03:24
If the next date minus the previous date equals one calendar day, increase the current run; otherwise start a new run at one. Update the best length on every iteration. Python's date arithmetic handles month boundaries and leap days, so do not add one to a day number by hand. Parsing invalid date strings should raise ValueError instead of silently inventing a date. The corrected function starts with zero for both best and current run, which gives the empty input a defined result. Each parsed day either extends the previous day by exactly one timedelta day or starts a new run of one. We update best after every date, not only when a run ends, so the final run cannot
- 04:06
be forgotten. The output is three for the sample. Pay attention to the kind of value being subtracted: date minus date yields a timedelta, which can cross February to March or December to January safely. The program has no hidden dependency on the current clock, so it remains reproducible in the browser exercise. Read the fixed code from top to bottom. The set comprehension calls date.fromisoformat once for each supplied value, which validates the format while preparing values for arithmetic. Sorting produces a stable sequence. In the loop, index zero cannot have a previous day, so it starts a run.
- 04:49
For every later day, the expression compares it with the immediately previous normalized day. If their difference equals one timedelta day, the run grows; otherwise it resets. best records the largest value seen so far. This design is linear after sorting, apart from the sort itself, and uses storage for distinct dates. Its output can be tested exactly without waiting for a real habit tracker to collect data. The final run uses a leap year and a duplicate February twenty-ninth. February twenty-eighth, February twenty- ninth, and March first form three different calendar days, so the answer is three.
- 05:29
The repeated February twenty-ninth contributes no extra day. An empty log returns zero. Try another variation yourself: include a gap after March first and a longer run later in the history. Check that the function returns the maximum, even when it does not end on the latest date. If a date string is malformed, date.fromisoformat should raise an error; do not silently treat it as a valid check- in. The article includes the task checker and a short quiz to test those boundaries. An empty log has longest streak zero. A leap-year sequence from February twenty- eighth through March first has three
- 06:09
calendar dates when February twenty-ninth is present. The verification run checks both this boundary and a duplicated middle date. Because this project computes the historical maximum, adding a missed old date may change the result even if the latest date does not. A future product could compute an active streak separately using a chosen timezone and today; this tutorial intentionally keeps that policy out of its simple function.
Understand the concept
A habit log is a set of calendar days, not a sequence of button presses. A person can submit the same date twice and can import older entries after newer ones. To find a streak, parse each ISO date with date.fromisoformat, remove duplicates, sort the resulting date objects, and compare adjacent days with timedelta(days=1). The longest streak is the largest consecutive run anywhere in the history; it is different from a current streak ending today. This offline project needs no account, clock, or network call.
The first implementation walks input order. It reports a two-day streak for March first, February twenty-seventh, and February twenty-eighth because those entries are out of order. A string comparison can also hide assumptions about formatting. Print the parsed dates and their sorted order before changing the algorithm. Duplicates are another trap: two records for one date must not add a day to the streak or interrupt a valid run. Decide what the function means before trying to optimize it.
After normalization, keep a current run length and a best run length. The first date starts a run of one. If the next date minus the previous date equals one calendar day, increase the current run; otherwise start a new run at one. Update the best length on every iteration. Python's date arithmetic handles month boundaries and leap days, so do not add one to a day number by hand. Parsing invalid date strings should raise ValueError instead of silently inventing a date.
An empty log has longest streak zero. A leap-year sequence from February twenty-eighth through March first has three calendar dates when February twenty-ninth is present. The verification run checks both this boundary and a duplicated middle date. Because this project computes the historical maximum, adding a missed old date may change the result even if the latest date does not. A future product could compute an active streak separately using a chosen timezone and today; this tutorial intentionally keeps that policy out of its simple function.
- Parse ISO strings into date objects
- Deduplicate before counting
- Sort before comparing adjacent days
- Compare dates with a one-day timedelta
See it step by step
Read the code, predict the output, then compare it with the result.
01. See chronological order
from datetime import date
raw = ['2026-03-01','2026-02-27','2026-02-28']
print(sorted(date.fromisoformat(value) for value in raw))
[datetime.date(2026, 2, 27), datetime.date(2026, 2, 28), datetime.date(2026, 3, 1)]Parsed date objects sort by actual calendar order, exposing why input order cannot be trusted.
02. Collapse repeated days
from datetime import date
raw = ['2024-02-28','2024-02-29','2024-02-29','2024-03-01']
print(len(sorted({date.fromisoformat(value) for value in raw})))
3Four check-ins contain three distinct calendar dates.
03. Cross leap day
from datetime import date, timedelta
a = date.fromisoformat('2024-02-29')
b = date.fromisoformat('2024-03-01')
print(b - a == timedelta(days=1))
TrueDate subtraction handles the leap-year transition without manual month logic.
A closer look
Follow the reasoning, inspect each result, then try the suggested changes in the console below.
Order the history
A habit log is a set of calendar days, not a sequence of button presses. A person can submit the same date twice and can import older entries after newer ones. To find a streak, parse each ISO date with date.fromisoformat, remove duplicates, sort the resulting date objects, and compare adjacent days with timedelta(days=1). The longest streak is the largest consecutive run anywhere in the history; it is different from a current streak ending today. This offline project needs no account, clock, or network call.
First predict what the raw loop does when a newer date appears before two older dates. Compare that with the chronological printout. A historical streak depends on date order, not file order or the time a record was imported. Explain why a set changes the input contract before using it.
from datetime import date
raw = ['2026-03-01','2026-02-27','2026-02-28']
print(sorted(date.fromisoformat(value) for value in raw))
[datetime.date(2026, 2, 27), datetime.date(2026, 2, 28), datetime.date(2026, 3, 1)]- Parse every ISO string.
- Compare original order with sorted date objects.
Count distinct check-in days
The first implementation walks input order. It reports a one-day streak for March first, February twenty-seventh, and February twenty-eighth because those entries are out of order. A string comparison can also hide assumptions about formatting. Print the parsed dates and their sorted order before changing the algorithm. Duplicates are another trap: two records for one date must not add a day to the streak or interrupt a valid run. Decide what the function means before trying to optimize it.
A user may click twice on one day. A streak counts days, so count distinct parsed dates. Run this example with another repeated value and verify that the number remains the number of calendar days. Keep a separate event-count metric if the product later needs to count every click.
from datetime import date
raw = ['2024-02-28','2024-02-29','2024-02-29','2024-03-01']
print(len(sorted({date.fromisoformat(value) for value in raw})))
3- Deduplicate parsed dates.
- Explain why a second check-in is not another day.
Test calendar boundaries
After normalization, keep a current run length and a best run length. The first date starts a run of one. If the next date minus the previous date equals one calendar day, increase the current run; otherwise start a new run at one. Update the best length on every iteration. Python's date arithmetic handles month boundaries and leap days, so do not add one to a day number by hand. Parsing invalid date strings should raise ValueError instead of silently inventing a date.
February has a variable length, and December to January crosses a year. Use timedelta comparison instead of checking integer day components. Try the 2024 leap-year boundary, the 2025 non-leap boundary, and an empty sequence after implementing the task.
from datetime import date, timedelta
a = date.fromisoformat('2024-02-29')
b = date.fromisoformat('2024-03-01')
print(b - a == timedelta(days=1))
True- Subtract adjacent dates.
- Test leap day and the empty list.
Try it in Python
Edit the example and run it. Python starts in your browser the first time you click Run.
Python console
Ready to runNeed input()? Add one value per line
Your output appears here.
Need a hint?
Build sorted({date.fromisoformat(value) for value in values}) before the loop; initialize best to zero and increase run only across a one-day timedelta.
Complete the task and select Check task to verify your code.
Quick quiz
Three questions. You can change your answers and try again.
Typical mistakes
Everyone meets these errors. See what causes them and how to fix them.
Trusting log order
for old, new in zip(days, days[1:])days = sorted(set(days))What happens: An imported old entry breaks a real streak.
Normalize chronology and collapse repeated check-ins before comparing neighbors.
Counting day numbers
new.day == old.day + 1new - old == timedelta(days=1)What happens: Month and leap-year boundaries fail.
Calendar date subtraction handles transitions between months and years.