Stopwatch
The stopwatch implements a solution to measure and collect timings: e.g. code execution times or test run times.
The time measurement can be started, paused,
resumed and stopped. More
over, split times can be taken too. The measurement is based on time.perf_counter_ns(). Additionally, starting and
stopping is preserved as absolute time via datetime.datetime.now().
Every split time taken is a time delta to the previous stopwatch operation. These are preserved in an internal sequence of splits. This sequence includes time deltas of activity and inactivity. Thus, a running stopwatch can be split as well as a paused stopwatch.
The stopwatch can also be used in a with-statement, because it implements the context manager protocol.
from pyTooling.Stopwatch import Stopwatch
sw = Stopwatch("my name")
sw.Start()
# do something
sw.Stop()
sw = Stopwatch("other name", started=True)
# do something
sw.Stop()
from pyTooling.Stopwatch import Stopwatch
sw = Stopwatch("my name")
sw.Start()
# do something
sw.Pause()
# do something other
sw.Resume()
# do something again
sw.Stop()
from pyTooling.Stopwatch import Stopwatch
sw = Stopwatch("my name", preferPause=True)
with sw:
# do something
# do something other
with sw
# do something again
Features
A stopwatch can be named at creation time.
The measurement can be started, paused, resumed and stopped.
Split times can be taken while the stopwatch runs.
Every split time is preserved, together with whether it measured activity or inactivity.
The split times can be iterated, indexed and counted.
Activity, inactivity and total duration are available at any time, also while the stopwatch runs - in seconds or in whole nanoseconds.
The stopwatch reports its state via four read-only properties.
Individual time spans can be excluded from the measurement.
The stopwatch is a context manager, and can pause or stop when the block ends.
Absolute start and stop times are recorded via
now(), next to the monotonic measurement viatime.perf_counter_ns().The rendered resolution is configurable, and a format specification renders the duration as hours, minutes and seconds, or wholly in seconds, milliseconds, microseconds or nanoseconds.
@export
class Stopwatch(SlottedObject):
def __init__(
self,
name: Nullable[str] = None,
started: bool = False,
preferPause: bool = False,
digits: int = 3,
) -> None:
...
def Start(self) -> None:
...
def Split(self) -> float:
...
def Pause(self) -> float:
...
def Resume(self) -> float:
...
def Stop(self) -> float:
...
@property
def Digits(self) -> int:
...
@Digits.setter
def Digits(self, digits: int) -> None:
...
@readonly
def Name(self) -> Nullable[str]:
...
@readonly
def IsStarted(self) -> bool:
...
@readonly
def IsRunning(self) -> bool:
...
@readonly
def IsPaused(self) -> bool:
...
@readonly
def IsStopped(self) -> bool:
...
@readonly
def StartTime(self) -> Nullable[datetime]:
...
@readonly
def StopTime(self) -> Nullable[datetime]:
...
@readonly
def HasSplitTimes(self) -> bool:
...
@readonly
def SplitCount(self) -> int:
...
@readonly
def ActiveCount(self) -> int:
...
@readonly
def InactiveCount(self) -> int:
...
@readonly
def Activity(self) -> float:
...
@readonly
def Inactivity(self) -> float:
...
@readonly
def Duration(self) -> float:
...
@readonly
def DurationInNanoseconds(self) -> int:
...
@readonly
def Exclude(self) -> ExcludeContextManager:
...
def __enter__(self) -> Self:
...
def __exit__(
self,
exc_type: Nullable[type[BaseException]] = None,
exc_val: Nullable[BaseException] = None,
exc_tb: Nullable[TracebackType] = None,
) -> Nullable[bool]:
...
def __len__(self) -> int:
...
def __getitem__(self, index: int) -> tuple[float, bool]:
...
def __iter__(self) -> Iterator[tuple[float, bool]]:
...
def __format__(self, formatSpec: str) -> str:
...
def __str__(self) -> str:
...
Out of Scope
Restarting or resetting. A stopwatch measures one time span in its lifetime. Create a new stopwatch instead of reusing a stopped one.
Nesting. A stopwatch measures one thing. Use one stopwatch per measured thing.
Thread-safety. Two threads operating one stopwatch will interleave their splits.
By Feature
Name
A stopwatch can be named at creation time, and the name is then a read-only property
Name. The name is optional and defaults to None. It appears in the
stopwatch’s string representation, which is what makes a name worth giving when several measurements are printed
together.
# Create a named stopwatch
sw = Stopwatch("parsing")
# Read the name back
name = sw.Name
Starting and Stopping
A stopwatch is started either by Start() or by passing started=True to the
constructor. Stop() ends the measurement and returns the duration since the
previous operation.
A stopwatch can be started once. Starting a running stopwatch, starting a stopped one, or stopping one that was
never started each raise a StopwatchError.
# Start explicitly
sw = Stopwatch("parsing")
sw.Start()
# do something
duration = sw.Stop()
# ... or start at creation time
sw = Stopwatch("parsing", started=True)
# do something
duration = sw.Stop()
Besides the monotonic measurement, the absolute times are recorded too, and are available as
StartTime and StopTime. Both are None
until the respective operation happened.
print(f"{sw.StartTime} -> {sw.StopTime}")
Pause and Resume
A running stopwatch can be paused with Pause() and continued with
Resume(). Both return the duration of the span that just ended, so a pause/resume
pair tells you both how long the work took and how long the interruption lasted.
Pausing a stopwatch that isn’t running, or resuming one that isn’t paused, raises a
StopwatchError.
sw = Stopwatch("parsing", started=True)
# do something
worked = sw.Pause()
# do something that shouldn't be measured
waited = sw.Resume()
# do something again
sw.Stop()
The paused span is not lost - it is recorded as an inactive split time, and shows up in
Inactivity. A stopwatch accounts for the whole span from start to stop; it never
silently drops time.
Split Times
Split() takes a split time while the stopwatch runs and returns the duration since
the previous operation. Splitting a stopwatch that isn’t running raises a
StopwatchError.
Split times are not a separate list of marks - every operation that ends a span records one. So
Split(), Pause(),
Resume() and Stop() all append one, and each
carries whether the span it measured was activity or inactivity:
Span |
Ended by |
Records |
|---|---|---|
start ⟶ split, resume ⟶ split |
|
activity |
start ⟶ pause, resume ⟶ pause |
|
activity |
pause ⟶ resume |
|
inactivity |
resume ⟶ stop |
|
activity |
pause ⟶ stop |
|
inactivity |
Important
A stopwatch that was never paused and never split records no split times at all - not even for the one span
from start to stop. Stop() still returns that duration, and
Duration still reports it, but there is nothing to iterate.
That is the distinction between the two kinds of result. Duration always
describes the measurement: start to stop, whatever happened in between. The split times, and therefore
Activity and Inactivity, describe
only the spans that were actually recorded, and are 0.0 when none were.
Once split times exist they are consecutive and cover the whole measurement, so they add up: the active spans sum to
Activity, the inactive ones to
Inactivity, and together they are
Duration.
Example
sw = Stopwatch("parsing", started=True)
sleep(0.1); sw.Split()
sleep(0.1); sw.Pause()
sleep(0.2); sw.Resume()
sleep(0.1); sw.Stop()
records four spans:
Start Split Pause Resume Stop
│ │ │ │ │
├──active──┼──active──┼─────inactive─────┼───active───┤
│ 0.1 s │ 0.1 s │ 0.2 s │ 0.1 s │
0.0 0.1 0.2 0.4 0.5 s
sw.SplitCount # 4
sw.ActiveCount # 3
sw.InactiveCount # 1
sw.Activity # 0.3
sw.Inactivity # 0.2
sw.Duration # 0.5
HasSplitTimes reports whether at least one split time was recorded, and
SplitCount how many.
Note
The counts and the durations describe the same spans, including the one that hasn’t ended yet.
ActiveCount and InactiveCount
answer “what would the stopwatch report if it stopped right now”: a running stopwatch is inside an active span,
so that span is counted, and a paused one is inside an inactive span.
That keeps them consistent with Activity and
Inactivity, which have always included the span in progress.
State |
The span in progress |
|---|---|
running |
counts towards |
paused |
counts towards |
stopped |
there is none - every span was recorded |
Iterating Split Times
The recorded split times are reachable as a sequence: __iter__() iterates them,
__getitem__() indexes them, and __len__()
counts them - the same number as SplitCount.
Each item is a tuple of the span’s duration in seconds and a boolean saying whether it was activity:
Usage
for duration, isActive in sw:
print(f"{duration:.3f} s {'running' if isActive else 'paused'}")
Result
0.100 s running
0.100 s running
0.200 s paused
0.100 s running
# The i-th span
duration, isActive = sw[0]
# The last span
duration, isActive = sw[-1]
# How many spans
count = len(sw)
Excluding Time Spans
Sometimes a measured region contains work that shouldn’t count - reading a fixture from disk inside a benchmark, or
waiting for a user. Exclude returns a context manager that pauses the stopwatch
when the block is entered and resumes it when the block ends.
It is the mirror image of using the stopwatch itself as a context manager, and it needs a running stopwatch - the excluded block is recorded as an ordinary inactive span.
Usage
sw = Stopwatch("benchmark", started=True)
for case in cases:
with sw.Exclude:
data = loadFixture(case) # not measured
process(data) # measured
sw.Stop()
Result
sw.Activity # only the process(...) calls
sw.Inactivity # only the loadFixture(...) calls
The same context manager object is returned on every access, so it can be used as often as needed.
Using in a with-statement
A stopwatch implements the context manager protocol, so a measured region can be written as a
with-block. Entering starts an unstarted stopwatch and resumes a paused one; leaving stops it, or pauses it when
the stopwatch was created with preferPause=True.
Entering a running or an already stopped stopwatch raises a StopwatchError.
With the default behaviour the block both starts and stops the measurement:
with Stopwatch("parsing") as sw:
# do something
print(sw.Duration)
With preferPause=True the block pauses instead of stopping, so the same stopwatch can measure several
regions and accumulate their durations. It has to be stopped explicitly at the end:
sw = Stopwatch("parsing", preferPause=True)
for file in files:
with sw:
parse(file) # measured
report(file) # not measured
sw.Stop()
print(sw.Activity) # time spent parsing
print(sw.Inactivity) # time spent reporting
Note
preferPause=True is the inverse of Exclude: the first measures what is
inside the blocks, the second measures what is outside them. Which to reach for depends on whether the
interesting work or the uninteresting work is the part that repeats.
State of a Stopwatch
A stopwatch reports its state through four read-only properties. They are not independent - the table shows the state after each operation:
After |
||||
|---|---|---|---|---|
creation |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Attention
IsStarted means “is started and not yet stopped”, so it turns False
again when the stopwatch is stopped. To ask whether a stopwatch was ever started, use
sw.IsStarted or sw.IsStopped.
Each operation is only valid in some of these states, and raises a StopwatchError
otherwise. Testing the state first is how a stopwatch is driven from code that doesn’t know its history:
if sw.IsRunning:
sw.Pause()
elif sw.IsPaused:
sw.Resume()
The Two Durations
Duration is the measurement in seconds, as a float.
DurationInNanoseconds is the same measurement in whole nanoseconds, as an
int - which is how time.perf_counter_ns() took it in the first place.
sw.Duration # 0.250294020
sw.DurationInNanoseconds # 250294020
Both answer for the whole measurement: start to stop, or start to now while the stopwatch runs, and 0 when it was
never started. Reach for the nanoseconds when a duration has to be divided into parts or compared exactly - that is
what __format__() does - and for seconds everywhere else.
Formatting
__str__() renders the stopwatch’s state and the duration it measured so far,
including the name if one was given. The duration is always in seconds, at the same resolution in every state, so
a running and a stopped stopwatch can be compared without converting anything.
Usage
sw = Stopwatch("parsing")
print(sw)
sw.Start()
print(sw)
sw.Stop()
print(sw)
Result
Stopwatch parsing: not started
Stopwatch parsing (running): 2026-08-27 15:50:12.150982 -> now: 0.500
Stopwatch parsing (stopped): 2026-08-27 15:50:12.150982 -> 2026-08-27 15:50:12.651257: 0.500
Resolution
How many fractional digits that duration is rendered with is set by Digits. It
defaults to 3 - milliseconds - and can be given at creation time or changed at any point, because it only decides
how the measurement is displayed.
# Give it at creation time
sw = Stopwatch("parsing", digits=6)
# ... or change it later
sw.Digits = 9
A value outside 0 to 9 raises a ValueError, since a duration in seconds has no more than nine
fractional digits to show - that is the resolution of the underlying time.perf_counter_ns().
sw.Digits = 6
print(sw)
Stopwatch parsing (stopped): 2026-08-27 15:50:12.150982 -> 2026-08-27 15:50:12.651257: 0.500269
Format Specification
Seconds are the right unit for a benchmark and the wrong one for a test run that lasted an hour. Python has no format
specification for durations - timedelta doesn’t implement __format__ at all, and
time.strftime() formats a point in time rather than a length of one - so the stopwatch brings its own, in the
same %-placeholder style as pyTooling.Versioning.SemanticVersion.__format__().
An uppercase specifier is a field of the duration as it would be displayed. A lowercase specifier is the whole duration expressed in one unit, which is what a report or a comparison wants.
Specifier |
Meaning |
|---|---|
|
hours, not capped - a 26 hour measurement shows |
|
minutes, |
|
seconds, |
|
fractional seconds, 3 digits (milliseconds) |
|
fractional seconds, 6 digits (microseconds) |
|
fractional seconds, 9 digits (nanoseconds) |
|
the whole duration in seconds |
|
the whole duration in milliseconds |
|
the whole duration in microseconds |
|
the whole duration in nanoseconds |
%% renders a literal percent sign, an empty specification falls back to
__str__(), and an unknown placeholder raises a ValueError.
Usage
For a stopwatch that measured 26 hours, 3 minutes and 4.123456789 seconds:
print(f"{sw:%H:%M:%S}")
print(f"{sw:%H:%M:%S.%L}")
print(f"{sw:%H:%M:%S.%U}")
print(f"{sw:%M:%S.%L}")
print(f"{sw:%s} s / {sw:%m} ms / {sw:%u} us / {sw:%n} ns")
Result
26:03:04
26:03:04.123
26:03:04.123456
03:04.123
93784 s / 93784123 ms / 93784123456 us / 93784123456789 ns
Note
The three fractional specifiers render the same fraction at three precisions rather than three consecutive
thirds of it, so %S.%U is complete on its own and nothing has to be chained.
%H deliberately isn’t capped at 23. There is no day specifier - a stopwatch measures code execution, so
%H:%M:%S is the longest form anyone needs - and capping it would make a 26 hour measurement silently render as
02:03:04.
Formatting works from DurationInNanoseconds, because dividing a duration
into fields wants a whole number of them. Not for precision - see
the two durations.