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 via time.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

Split()

activity

start ⟶ pause, resume ⟶ pause

Pause()

activity

pause ⟶ resume

Resume()

inactivity

resume ⟶ stop

Stop() while running

activity

pause ⟶ stop

Stop() while paused

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 ActiveCount

paused

counts towards InactiveCount

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

IsStarted

IsRunning

IsPaused

IsStopped

creation

False

False

False

False

Start()

True

True

False

False

Pause()

True

False

True

False

Resume()

True

True

False

False

Stop()

False

False

False

True

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.

Note

Precision is not what separates them. A float holds a duration in seconds exactly, to the nanosecond, up to \(2^{53}\) nanoseconds - a little over 104 days - so at any length a stopwatch measures the two are interchangeable, and DurationInNanoseconds / 1e9 equals Duration exactly.

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

%H

hours, not capped - a 26 hour measurement shows 26

%M

minutes, 00 to 59

%S

seconds, 00 to 59

%L

fractional seconds, 3 digits (milliseconds)

%U

fractional seconds, 6 digits (microseconds)

%N

fractional seconds, 9 digits (nanoseconds)

%s

the whole duration in seconds

%m

the whole duration in milliseconds

%u

the whole duration in microseconds

%n

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.