The biggest speedups almost always come from three things, in this order: measuring to find the real bottleneck, picking a better algorithm or data structure, and avoiding repeated work (caching, fewer I/O calls). Micro-tweaks like swapping a multiply for a shift rarely matter. The ten techniques below are ordered roughly by how much they usually buy you. Examples are in Python, and every output shown was produced by running the code.
1. Measure before you change anything
Guessing where code is slow is wrong surprisingly often. Profile first, change one thing, then profile again.
- Python:
cProfileshows time per function, andtimeittimes small snippets. - JavaScript in the browser: the Performance tab in Chrome DevTools.
- Anything: a simple timer around the suspicious section, run several times on realistic data.
import cProfile
cProfile.run("main()", sort="cumulative") # replace main() with your entry point
Donald Knuth’s often-quoted line is “premature optimization is the root of all evil”. The full sentence is about small efficiencies in non-critical parts of a program: you should not spend effort on them, but you should not pass up the critical few percent either. Profiling is how you find that few percent.
2. Choose a better algorithm
This is where order-of-magnitude gains live. If the input doubles and your runtime quadruples, you probably have a nested loop that a different approach can remove. Compare two ways to check for duplicates:
def dup_quadratic(nums):
for i in range(len(nums)):
for j in range(i + 1, len(nums)):
if nums[i] == nums[j]:
return True
return False
def dup_set(nums):
seen = set()
for x in nums:
if x in seen:
return True
seen.add(x)
return False
print(dup_quadratic([1, 2, 3, 2]), dup_set([1, 2, 3, 2])) # True True
data = list(range(3000))
print(dup_quadratic(data), dup_set(data)) # False False
Both give the same answers. The first is O(n^2) and does about 4.5 million comparisons on the 3,000 items above; the second is O(n) on average. In a quick timing run the quadratic version was more than 20 times slower on that input, and the gap keeps growing with size. The rule of thumb: sorting a large input needs an O(n log n) algorithm, not bubble sort, and any “for each item, scan the whole list” pattern is a hint to use a hash map or sort first. If you need a refresher on the notation, see time complexity explained.
3. Pick the right data structure
The same idea applies to the container you choose:
- Membership tests (
x in collection): alistscans every element (O(n)); asetordictis O(1) on average. In a timing test with 100,000 numbers, checking for the last element in the set was over 50 times faster than in the list. - Take items from the front: use
collections.deque;list.pop(0)is O(n). - Always need the smallest item: a heap (
heapq) instead of re-sorting. - Lookups by key: a dictionary instead of searching a list of pairs.
The Hash Map lesson practices exactly this trade.
4. Cache repeated work
If a function is pure (the same input always gives the same output) and called repeatedly with the same arguments, store the result. In Python, functools.lru_cache does it in one line:
from functools import lru_cache
calls = 0
def fib(n):
global calls
calls += 1
return n if n < 2 else fib(n - 1) + fib(n - 2)
fib(25)
print("plain calls:", calls) # plain calls: 242785
@lru_cache(maxsize=None)
def fib_cached(n):
return n if n < 2 else fib_cached(n - 1) + fib_cached(n - 2)
fib_cached(25)
print(fib_cached.cache_info()) # CacheInfo(hits=23, misses=26, maxsize=None, currsize=26)
242,785 calls become 26 real computations. This is memoization, the core idea of top-down dynamic programming; see tabulation and memoization. In applications the same idea appears as Redis or Memcached for shared caches. The hard parts are not the lookup but the rules: how long entries live, when to evict them, and how to invalidate them when the underlying data changes. A cache that serves stale data is a bug. Also keep an eye on memory, since an unbounded cache is a leak (use maxsize when keys are many).
5. Do less input and output
Reading files, querying databases and calling APIs are usually orders of magnitude slower than in-memory work. Reduce the number of round trips:
- Batch: one query that fetches 100 rows beats 100 queries that fetch one. This is the classic “N+1 query” problem in database code.
- Buffer: write in larger chunks rather than one tiny write per line.
- Read what you need: select specific columns, paginate, and stream large files line by line.
- Overlap waiting: async I/O or threads let you wait on several network calls at once.
6. Use less memory: generators and streaming
Do not build a million-item list if you only need to loop over it once. A generator produces items on demand:
import sys
big_list = [i * i for i in range(1_000_000)]
gen = (i * i for i in range(1_000_000))
print(sys.getsizeof(big_list) > 1_000_000) # True: megabytes just for the list's pointers
print(sys.getsizeof(gen) < 500) # True: the generator object is tiny
print(sum(i * i for i in range(1_000_000)) == sum(big_list)) # True
The generator holds only its current state. Other memory savers: iterate over files line by line, avoid copying large lists (slicing copies), and use sets when you only need uniqueness. Note that setting a variable to None rarely helps in garbage-collected languages; the memory is reclaimed once nothing references it, so the real fix is usually a narrower scope or streaming.
7. Vectorize numeric work
In Python, a loop over a million numbers runs interpreter code a million times. NumPy performs the loop in compiled code:
import numpy as np
a = np.arange(1_000_000, dtype=np.int64)
loop = sum(int(x) * 2 for x in a)
vec = int((a * 2).sum())
print(loop == vec, vec) # True 999999000000
Same result; the vectorized line was more than 10 times faster in a quick timing run. The same idea applies in pandas (avoid row-by-row apply when a column operation exists) and in SQL (let the database aggregate instead of pulling rows into your code). Vectorizing makes the code shorter too, but check that the dtype can hold your values to avoid overflow.
8. Build strings and collections efficiently
Many small repeated operations hide quadratic costs. In languages where strings are immutable, concatenating in a loop can copy the whole string every time. Collect the parts and join once:
parts = [str(i) for i in range(1000)]
s = "".join(parts) # one pass, instead of s += p in a loop
CPython sometimes optimizes += on strings, but that is an implementation detail you should not rely on. Similar traps: calling len() or a database lookup inside a loop condition, recomputing the same expression inside a loop (move it out), and repeatedly slicing or sorting inside a loop.
9. Use parallelism for the right kind of work
Parallelism helps when you have many independent tasks. Match the tool to the kind of work:
- Waiting on the network or disk: threads or
asyncioin Python; promises in JavaScript. - Heavy CPU work in Python: use processes (
multiprocessingorconcurrent.futures.ProcessPoolExecutor), because of the Global Interpreter Lock in standard CPython threads. Recent Python versions also offer an optional free-threaded build; check the docs for your version. - C, C++, Java, Go, Rust: real threads, or libraries such as OpenMP for loops.
It also adds risks: race conditions, deadlocks, and overhead that can make small tasks slower. Parallelism cannot rescue a bad algorithm, so apply it after sections 2 to 5, and measure.
10. Let the compiler and runtime help, and know what not to micro-optimize
Compilers for C, C++, Rust, Go and Java’s JIT already do constant folding, dead-code elimination, inlining and loop optimizations. Turn them on (for example -O2 on GCC or Clang for release builds) and write clear code instead of hand-tuning. Folklore like “shifts are faster than multiplies” is usually already handled by the compiler, and the small gains are not worth the loss in readability.
On the same note, “avoid global variables” is good design advice, because globals make code harder to test and reason about. Their performance cost in Python is real but small: a global is looked up in a dictionary, a local in a fast slot. Fix globals for clarity; do not expect a big speedup from it.
For web pages specifically, lazy loading defers work until needed: add loading="lazy" to below-the-fold images and load non-critical scripts with defer or async.
A simple workflow
- Write the clearest correct version first. Add tests.
- If it is too slow, profile with realistic data.
- Fix the top item: algorithm, data structure, repeated work, I/O. Re-measure.
- Stop when it meets your target. Further tuning costs readability.
Choosing algorithms well is also what coding interviews test; the Big O lessons and the hash-table exercises are a good place to practice it.
