How to find the time complexity of a program in Python

Learn how to find the time complexity of your Python programs. Discover different methods, tips, real-world applications, and how to debug errors.

How to find the time complexity of a program in Python
Published on: 
Tue
Apr 21, 2026
Updated on: 
Tue
Apr 21, 2026
The Replit Team

Time complexity analysis helps you understand how your Python program's runtime scales with input size. It's a crucial skill for code optimization and performance improvement.

Here, you'll learn techniques to calculate time complexity, with practical tips and real-world applications. You'll also get advice to debug performance issues and write faster, more scalable Python code.

Using the time module for basic timing

import time

start_time = time.time()
sorted([5, 2, 8, 1, 3])
end_time = time.time()
print(f"Execution time: {end_time - start_time} seconds")--OUTPUT--Execution time: 0.0000591278076171875 seconds

The time module offers a straightforward method for basic benchmarking. You can capture timestamps before and after an operation, like sorted(), using time.time(). The difference between these values reveals the total execution time for that specific run.

This technique measures what's known as "wall-clock time"—the actual time that passes on a clock. It’s a great starting point for a quick performance check. Remember, this value can be influenced by other system processes, so it isn't a pure measure of your algorithm's efficiency alone.

Standard timing and profiling techniques

To isolate your code's performance from system noise and truly understand its efficiency, you'll need to move beyond basic timing to more robust methods.

Using the timeit module for accurate measurements

import timeit

def test_function():
return sorted([5, 2, 8, 1, 3])

execution_time = timeit.timeit(test_function, number=10000)
print(f"Average execution time: {execution_time/10000} seconds")--OUTPUT--Average execution time: 0.0000023156 seconds

The timeit module is your go-to for more precise measurements. Unlike time.time(), it's designed to minimize interference from other processes running on your machine. It achieves this by running your code snippet—in this case, test_function—many times in a row.

  • The number parameter specifies how many times the function should execute.
  • By averaging the results over thousands of runs, you get a much more stable and representative measure of your code's performance.

Understanding Big O notation with examples

def linear_search(arr, target): # O(n) time complexity
for i, item in enumerate(arr):
if item == target:
return i
return -1

arr = [1, 2, 3, 4, 5]
print(f"Found at index: {linear_search(arr, 3)}")--OUTPUT--Found at index: 2

Big O notation helps you understand an algorithm's worst-case performance. The linear_search function is a classic example of O(n) complexity, or linear time. This means its execution time grows in direct proportion to the size of the input list, which we call 'n'.

  • The function iterates through the list one item at a time. In the worst-case scenario, it has to check every single element to find the target.

So, if your list doubles in size, the time it takes to search will also roughly double.

Creating a timing decorator for function analysis

import time

def timing_decorator(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} took {end - start} seconds to run")
return result
return wrapper

@timing_decorator
def bubble_sort(arr):
for i in range(len(arr)):
for j in range(0, len(arr) - i - 1):
if arr[j] > arr[j + 1]:
arr[j], arr[j + 1] = arr[j + 1], arr[j]
return arr

bubble_sort([5, 3, 8, 1, 2])--OUTPUT--bubble_sort took 0.0000762939453125 seconds to run

A decorator is a powerful Python feature that lets you wrap one function with another. The timing_decorator adds timing logic to any function you apply it to—like bubble_sort—without modifying the function's source code. This makes it a clean, reusable tool for performance analysis.

  • The @timing_decorator syntax automatically applies the wrapper to the function defined below it.
  • It works by recording the time before and after your function runs, then printing the elapsed duration.
  • You can apply this decorator to any function to quickly benchmark its execution time.

Advanced profiling and complexity analysis

Moving beyond simple timers, you can use powerful profilers like cProfile and tracemalloc to pinpoint bottlenecks and analyze memory usage with precision.

Detailed profiling with cProfile

import cProfile

def factorial(n):
if n <= 1:
return 1
return n * factorial(n - 1)

cProfile.run('factorial(10)')--OUTPUT--11 function calls (4 primitive calls) in 0.000 seconds

Ordered by: standard name

ncalls tottime percall cumtime percall filename:lineno(function)
1 0.000 0.000 0.000 0.000 <string>:1(<module>)
8/1 0.000 0.000 0.000 0.000 <stdin>:1(factorial)
1 0.000 0.000 0.000 0.000 {built-in method builtins.exec}
1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}

The cProfile module gives you a function-by-function breakdown of your code's performance. When you run cProfile.run('factorial(10)'), it analyzes the execution and prints a detailed report that helps you pinpoint exact bottlenecks.

  • The ncalls column shows how many times each function was called. You can see the recursive nature of factorial in its call count.
  • cumtime, or cumulative time, reveals the total time spent in a function and any sub-functions it calls, making it easy to spot the most expensive operations.

Empirical analysis with different input sizes

import time

def measure_complexity(func, sizes):
times = []
for size in sizes:
data = list(range(size))
start = time.time()
func(data)
end = time.time()
times.append(end - start)
return times

def insertion_sort(arr):
for i in range(1, len(arr)):
key = arr[i]
j = i - 1
while j >= 0 and arr[j] > key:
arr[j + 1] = arr[j]
j -= 1
arr[j + 1] = key
return arr

sizes = [1000, 2000, 4000]
times = measure_complexity(insertion_sort, sizes)
for size, time_taken in zip(sizes, times):
print(f"Size: {size}, Time: {time_taken:.6f} seconds")--OUTPUT--Size: 1000, Time: 0.031288 seconds
Size: 2000, Time: 0.126521 seconds
Size: 4000, Time: 0.495773 seconds

Empirical analysis lets you observe how an algorithm's performance scales in the real world. The measure_complexity function automates this process. It runs insertion_sort on lists of increasing sizes and records the execution time for each, giving you concrete data to analyze.

  • Notice how doubling the input size from 2000 to 4000 causes the execution time to roughly quadruple.

This pattern is a classic sign of O(n^2) complexity, where runtime grows quadratically with the input size.

Memory profiling with tracemalloc

import tracemalloc

def analyze_memory_usage(func, *args, **kwargs):
tracemalloc.start()
result = func(*args, **kwargs)
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"Current memory usage: {current / 10**6} MB")
print(f"Peak memory usage: {peak / 10**6} MB")
return result

def create_large_list(n):
return [i for i in range(n)]

analyze_memory_usage(create_large_list, 1000000)--OUTPUT--Current memory usage: 33.578954 MB
Peak memory usage: 41.988304 MB

The tracemalloc module is essential for understanding your code's memory footprint. The analyze_memory_usage function wraps any function you want to inspect, like create_large_list. It uses tracemalloc.start() to begin monitoring memory allocations before your function runs and tracemalloc.stop() after it finishes.

  • The get_traced_memory() function returns two key values.
  • current memory shows how much memory the traced block is using at the end.
  • peak memory reveals the maximum amount used at any point during execution—often the most critical metric for spotting memory-hungry operations.

Move faster with Replit

Replit is an AI-powered development platform that lets you skip setup and start coding instantly. All Python dependencies are pre-installed, so you can move from learning individual techniques to building complete applications with Agent 4. It takes your idea and builds a working product by handling the code, databases, APIs, and deployment directly from a description.

Instead of piecing together functions, you can build complete, efficient applications:

  • A custom data analysis tool that sorts and filters large datasets with optimal performance.
  • A lightweight API that processes user inputs while maintaining a low memory footprint.
  • A file conversion utility that reports on the time taken to process each file, helping identify bottlenecks in a workflow.

Simply describe your app, and Replit will write the code, test it, and fix issues automatically, all within your browser.

Common errors and challenges

When analyzing your code's performance, you'll likely encounter a few common pitfalls that can skew your results and lead to incorrect conclusions.

Fixing timing inaccuracy with time.time() for fast operations

For operations that execute in microseconds, time.time() can be misleading. The function's resolution might not be fine enough to capture the duration, often resulting in a measurement of zero. The overhead from the timing calls themselves can also be larger than the operation you're trying to measure, further distorting the results.

A simple fix is to run the fast operation inside a loop many times. By timing the entire loop and then dividing the total duration by the number of iterations, you can calculate a much more stable and accurate average time for a single execution.

Resolving scope issues in timeit measurements

Because the timeit module executes code in an isolated namespace to ensure accuracy, it can't access variables or functions from your main script's scope by default. This isolation is a frequent source of NameError exceptions, as the code being timed can't find the resources it needs to run.

You can resolve this by explicitly passing your script's global namespace to the function using the globals parameter. This gives the timed snippet access to the necessary context, allowing it to run without scope-related errors while still benefiting from timeit's precision.

Preventing redundant calculations in recursive functions

Recursive functions often re-calculate the same values multiple times, leading to major performance drains. A naive recursive function to calculate Fibonacci numbers, for instance, will compute the same sub-problems over and over, resulting in an exponential increase in execution time as the input grows.

The solution is memoization, a technique where you store the results of expensive function calls and return the cached result when the same inputs appear again. This simple optimization offers significant benefits:

  • It drastically reduces the total number of computations needed.
  • It can improve an algorithm's time complexity from exponential to linear.
  • You can implement it easily in Python with a dictionary or the built-in functools.lru_cache decorator.

Fixing timing inaccuracy with time.time() for fast operations

When an operation is extremely fast, time.time() often lacks the precision to measure it accurately, which can return a misleading result of zero. This makes it seem like your code took no time at all. The following example shows this issue.

import time

def measure_list_operation():
start = time.time() # Low precision timer
result = [1, 2, 3, 4, 5].index(3)
end = time.time()
return end - start

print(f"Operation took {measure_list_operation()} seconds") # Often shows 0.0

The index() method is so efficient that the time between the start and end calls is often too small for time.time() to measure. See how to get a more reliable result with a simple adjustment.

import time

def measure_list_operation():
start = time.perf_counter() # High precision timer
result = [1, 2, 3, 4, 5].index(3)
end = time.perf_counter()
return end - start

print(f"Operation took {measure_list_operation()} seconds")

To get a more reliable measurement for quick operations, swap time.time() with time.perf_counter(). This function provides a high-precision timer that’s independent of the system clock, making it ideal for benchmarking. It accurately captures the short durations of efficient methods like index(). You'll want to use this approach whenever you're timing operations that complete in microseconds, as time.time() can misleadingly report a duration of zero.

Resolving scope issues in timeit measurements

The timeit module runs code in an isolated scope to prevent outside variables from influencing the benchmark. This isolation can cause a NameError if your function relies on data defined elsewhere. The following code shows what happens when a function can't find its data.

import timeit

data = [5, 3, 8, 1, 2]

def process_data():
# This will fail because 'data' isn't in timeit's scope
return sorted(data)

time_taken = timeit.timeit(process_data, number=1000)
print(f"Time: {time_taken/1000} seconds")

The process_data function fails because timeit can't find the data list, which is defined outside its execution scope. The following code shows how to give your function the context it needs to run properly.

import timeit

data = [5, 3, 8, 1, 2]

# Using globals parameter to make 'data' available
time_taken = timeit.timeit(
'sorted(data)',
globals={'data': data},
number=1000
)
print(f"Time: {time_taken/1000} seconds")

To solve the scope issue, pass the necessary variables into timeit.timeit() using the globals parameter. This parameter accepts a dictionary where keys are variable names and values are the objects themselves. By setting globals={'data': data}, you make the external data list available inside timeit’s isolated environment. This ensures your benchmark runs without a NameError while maintaining measurement accuracy. Watch for this whenever you time code that relies on external data.

Preventing redundant calculations in recursive functions

Recursive functions are a powerful tool, but they can be incredibly inefficient if they repeatedly calculate the same results. The classic fibonacci function is a prime offender, leading to exponential slowdowns as it re-computes identical values. See this performance trap in action below.

import time

def fibonacci(n):
if n <= 1:
return n
return fibonacci(n-1) + fibonacci(n-2) # Redundant calculations

start = time.time()
result = fibonacci(30)
end = time.time()
print(f"Result: {result}, Time: {end - start} seconds")

The fibonacci function's recursive structure forces it to re-calculate the same numbers repeatedly, causing the significant slowdown. For example, fibonacci(3) is computed multiple times. The following code shows how to prevent this unnecessary work.

import time

def fibonacci(n, memo={}):
if n in memo:
return memo[n]
if n <= 1:
return n
memo[n] = fibonacci(n-1, memo) + fibonacci(n-2, memo)
return memo[n]

start = time.time()
result = fibonacci(30)
end = time.time()
print(f"Result: {result}, Time: {end - start} seconds")

The solution is memoization, which caches results to avoid re-computation. The improved fibonacci function uses a memo dictionary to store previously calculated values. Before computing, it checks if the result for n is already in the dictionary.

If the value exists, it's returned instantly. If not, the function computes the value, stores it in memo, and then returns it. This turns an exponential problem into a linear one. Watch for this pattern in any recursive function that solves overlapping sub-problems.

Real-world applications

Beyond fixing errors, these techniques help you make informed decisions in real-world scenarios like data structure selection and text processing.

Comparing data structures for membership testing with time

Your choice between a list and a set can make a huge difference in performance, particularly for search operations using the in operator.

import time

# Create test data
list_data = list(range(100000))
set_data = set(list_data)
search_item = 99999 # Worst case for list

# Time list membership test
start = time.time()
result_list = search_item in list_data
list_time = time.time() - start

# Time set membership test
start = time.time()
result_set = search_item in set_data
set_time = time.time() - start

print(f"List search time: {list_time:.6f} seconds")
print(f"Set search time: {set_time:.6f} seconds")
print(f"Set is {list_time/set_time:.0f}x faster for lookups")

This code pits a list against a set to see which is faster for membership testing with the in operator. It creates a large dataset for both and then times how long it takes to find an element. The search_item is intentionally placed at the end, forcing the list to work through every single element before finding a match.

  • A list lookup can be slow because it may need to check each item one by one.
  • A set is optimized for lookups, making the operation almost instant.

This makes sets a better choice when you need to frequently check if an item exists.

Optimizing text processing with profiling

Profiling your code can reveal major performance gains in text processing, especially when you replace manual methods with optimized, built-in tools.

import time
import re

def naive_word_count(text):
# Remove punctuation character by character
clean_text = ""
for char in text:
if char.isalnum() or char.isspace():
clean_text += char

# Count word occurrences
words = clean_text.lower().split()
word_count = {}
for word in words:
if word in word_count:
word_count[word] += 1
else:
word_count[word] = 1

return word_count

def optimized_word_count(text):
from collections import Counter
words = re.findall(r'\b[a-z0-9]+\b', text.lower())
return Counter(words)

# Generate sample text
sample_text = "Hello world! This is a test. Hello again, world." * 1000

# Time the naive approach
start = time.time()
result1 = naive_word_count(sample_text)
naive_time = time.time() - start

# Time the optimized approach
start = time.time()
result2 = optimized_word_count(sample_text)
optimized_time = time.time() - start

print(f"Naive implementation: {naive_time:.6f} seconds")
print(f"Optimized implementation: {optimized_time:.6f} seconds")
print(f"Performance improvement: {naive_time/optimized_time:.1f}x faster")

This code compares two methods for counting words to show how your choice of tools impacts performance. The naive_word_count function uses basic loops to clean the text and then count words, which is inefficient for large inputs. The optimized_word_count function, however, uses specialized, high-performance modules.

  • It uses re.findall() to extract all words in one fast operation.
  • It then uses collections.Counter, a tool designed for efficient frequency counting.

By timing both, you can see the dramatic speed increase from using the right tools for the job.

Get started with Replit

Turn your new skills into a practical tool with Replit Agent. Try prompts like, “Build a Python utility that benchmarks a function’s performance” or “Create a dashboard that visualizes list vs. set lookup times.”

Replit Agent writes the code, tests for errors, and deploys your application. Start building with Replit.

Get started free

Create and deploy websites, automations, internal tools, data pipelines and more in any programming language without setup, downloads or extra tools. All in a single cloud workspace with AI built in.

Get started free

Create and deploy websites, automations, internal tools, data pipelines and more in any programming language without setup, downloads or extra tools. All in a single cloud workspace with AI built in.