Skip to content

Top 30 Python Coding Challenges with Solutions [2026]

September 29, 2026 9 min read
Python interview guide by Second Talent
TL;DR: Python coding rounds in 2026 test the same core patterns as other languages: hash maps, sliding windows, heaps, BFS and dynamic programming. What sets Python answers apart is knowing the standard library, such as Counter, deque, heapq, bisect and functools.cache. Python 3.14, released on October 7, 2025, is the current version.

Python 3.14 added template strings, except clauses without brackets and max-heap functions in heapq, and it made the free-threaded build officially supported. None of that changes the classic interview problems.

It does change what a sharp candidate writes: fewer hand-rolled helpers, more standard library, and answers that say which Python version they assume.

Key takeaways
  1. 1The newest patch release is Python 3.14.7. Python 3.13, from October 7, 2024, added copy.replace() and a strict flag for itertools.batched().
  2. 2On the free-threaded 3.14 build, single-threaded code runs roughly 5 to 10% slower, per the 3.14 release notes, in exchange for real thread parallelism.
  3. 3CPython's default recursion limit is 1,000 frames, so recursive DFS on a large grid fails where an iterative BFS does not.
  4. 4Dicts have kept insertion order as a language guarantee since Python 3.7, which several solutions below rely on.

Beginner Challenges

These test strings, lists and dicts. Watch for the idiomatic version: slicing, comprehensions and collections instead of index loops.

1. Check whether a sentence is a palindrome.

Task: ignore case and anything that is not a letter or digit. "A man, a plan, a canal: Panama" returns True.

def is_palindrome(s: str) -> bool:
    cleaned = [c.lower() for c in s if c.isalnum()]
    return cleaned == cleaned[::-1]

O(n) time and O(n) memory. str.isalnum() is Unicode-aware, so accented letters count as letters. A two-pointer loop brings memory down to O(1); ask for it as a follow-up.

2. Return the k most common words in a text.

Task: "the cat and the hat and the bat" with k = 3 returns [('the', 3), ('and', 2), ('cat', 1)].

import re
from collections import Counter

def top_words(text: str, k: int = 3) -> list[tuple[str, int]]:
    words = re.findall(r"[a-z']+", text.lower())
    return Counter(words).most_common(k)

most_common orders ties by first appearance, which is why cat beats hat. Splitting on spaces alone would count "the," and "the" as different words.

3. Find the index of the first character that does not repeat.

Task: "leetcode" returns 0, "loveleetcode" returns 2, and "aabb" returns -1.

from collections import Counter

def first_unique(s: str) -> int:
    counts = Counter(s)
    return next((i for i, c in enumerate(s) if counts[c] == 1), -1)

Two passes, O(n). The default argument of next() replaces a loop with a flag variable.

4. Check whether two phrases are anagrams.

Task: ignore spaces and case. "Dormitory" and "dirty room" return True.

from collections import Counter

def is_anagram(a: str, b: str) -> bool:
    def key(s: str) -> Counter[str]:
        return Counter(s.replace(" ", "").lower())
    return key(a) == key(b)

Comparing two Counters is O(n). sorted(a) == sorted(b) is shorter but O(n log n).

5. Flatten a list nested to any depth.

Task: [1, [2, [3, [4]], 5]] becomes [1, 2, 3, 4, 5].

from collections.abc import Iterator

def flatten(items: list) -> Iterator:
    for item in items:
        if isinstance(item, list):
            yield from flatten(item)
        else:
            yield item

# list(flatten([1, [2, [3, [4]], 5]])) returns [1, 2, 3, 4, 5]

A generator yields values lazily, so it never builds lists in between. The trap is checking for any iterable instead of list: a one-letter string is iterable and yields itself, so the recursion never ends.

6. Rotate a list to the right by k steps.

Task: [1, 2, 3, 4, 5, 6, 7] with k = 3 returns [5, 6, 7, 1, 2, 3, 4]. k may be larger than the list.

def rotate(nums: list[int], k: int) -> list[int]:
    if not nums:
        return []
    k %= len(nums)
    return nums[-k:] + nums[:-k]

The modulo handles k larger than the list and the empty check avoids dividing by zero. When k is 0, nums[-0:] is the whole list and nums[:-0] is empty, so the result is still correct. collections.deque.rotate(k) does the same in place.

Intermediate Challenges

Mid-level rounds are about naming the right pattern and the right standard library tool. A candidate who writes a manual binary search when bisect fits should at least say why.

7. Two Sum: find two indices whose values add up to a target.

Task: [2, 7, 11, 15] with target 9 returns [0, 1].

def two_sum(nums: list[int], target: int) -> list[int]:
    seen: dict[int, int] = {}
    for i, n in enumerate(nums):
        if target - n in seen:
            return [seen[target - n], i]
        seen[n] = i
    return []

One pass, O(n). Checking before inserting stops a number from pairing with itself.

8. Check that brackets are balanced.

Task: "([]{})" returns True; "(]" and "((" return False.

def is_valid(s: str) -> bool:
    pairs = {")": "(", "]": "[", "}": "{"}
    stack = []
    for c in s:
        if c in pairs:
            if not stack or stack.pop() != pairs[c]:
                return False
        else:
            stack.append(c)
    return not stack

A plain list is the idiomatic stack: append and pop at the end are O(1). The final not stack catches unclosed openers.

9. Group words that are anagrams of each other.

Task: ["eat", "tea", "tan", "ate", "nat", "bat"] returns three groups: eat/tea/ate, tan/nat and bat.

from collections import defaultdict

def group_anagrams(words: list[str]) -> list[list[str]]:
    groups: defaultdict[str, list[str]] = defaultdict(list)
    for w in words:
        groups["".join(sorted(w))].append(w)
    return list(groups.values())

O(n k log k) for n words of length k. The key must be hashable, which is why it is a string and not the list sorted() returns.

Two columns pairing hand-rolled code with the standard library tool to use instead: Counter for counting, defaultdict for grouping, deque for queues, heapq.nlargest for top k, bisect for binary search and functools.cache for memoization.

10. Find the longest substring without repeating characters.

Task: "abcabcbb" returns 3; "abba" returns 2.

def longest_unique(s: str) -> int:
    last: dict[str, int] = {}
    start = best = 0
    for end, c in enumerate(s):
        if last.get(c, -1) >= start:
            start = last[c] + 1
        last[c] = end
        best = max(best, end - start + 1)
    return best

A sliding window, O(n). The >= start check stops the window moving backwards on "abba", the input that breaks most first attempts.

11. Merge overlapping intervals.

Task: [[1, 3], [2, 6], [8, 10], [15, 18]] returns [[1, 6], [8, 10], [15, 18]].

def merge(intervals: list[list[int]]) -> list[list[int]]:
    out: list[list[int]] = []
    for start, end in sorted(intervals):
        if out and start <= out[-1][1]:
            out[-1][1] = max(out[-1][1], end)
        else:
            out.append([start, end])
    return out

O(n log n) for the sort. sorted() returns a new list and each output interval is new, so the caller's data is untouched.

12. Return the k most frequent values.

Task: [1, 1, 1, 2, 2, 3] with k = 2 returns [1, 2].

import heapq
from collections import Counter

def top_k_frequent(nums: list[int], k: int) -> list[int]:
    counts = Counter(nums)
    return heapq.nlargest(k, counts, key=counts.get)

heapq.nlargest keeps a heap of size k, so this is O(n log k). Sorting all counts is O(n log n), which matters once there are millions of distinct values.

13. Find the first and last position of a value in a sorted list.

Task: [5, 7, 7, 8, 8, 10] with 8 returns [3, 4]; with 6 it returns [-1, -1].

from bisect import bisect_left, bisect_right

def search_range(nums: list[int], target: int) -> list[int]:
    lo = bisect_left(nums, target)
    if lo == len(nums) or nums[lo] != target:
        return [-1, -1]
    return [lo, bisect_right(nums, target) - 1]

Two binary searches, O(log n). bisect_left returns where the value would go, not whether it exists, so the check after it is required.

14. Rotate a square matrix 90 degrees clockwise.

Task: [[1, 2, 3], [4, 5, 6], [7, 8, 9]] becomes [[7, 4, 1], [8, 5, 2], [9, 6, 3]].

def rotate_matrix(m: list[list[int]]) -> list[list[int]]:
    return [list(row) for row in zip(*m[::-1])]

Reverse the rows, then zip(*...) reads the columns. That is O(n2) and returns a new matrix. For an in-place version, transpose by swapping m[i][j] and m[j][i], then reverse each row.

15. Traverse a binary tree level by level.

Task: for the tree 3, then 9 and 20, then 15 and 7, return [[3], [9, 20], [15, 7]].

from collections import deque

def level_order(root: TreeNode | None) -> list[list[int]]:
    if root is None:
        return []
    levels, queue = [], deque([root])
    while queue:
        level = []
        for _ in range(len(queue)):
            node = queue.popleft()
            level.append(node.val)
            queue.extend(child for child in (node.left, node.right) if child)
        levels.append(level)
    return levels

Breadth-first search, O(n). deque.popleft() is O(1), while list.pop(0) shifts every element and is O(n).

list
  • append and pop() at the end are fast
  • pop(0) and insert(0, x) are O(n)
  • O(1) indexing anywhere: the right stack
collections.deque
  • Appends and pops at either end are O(1)
  • Indexing slows to O(n) in the middle
  • The right queue for BFS

16. Count the islands in a grid.

Task: "1" is land and "0" is water. Land cells that touch up, down, left or right form one island.

from collections import deque

def num_islands(grid: list[list[str]]) -> int:
    rows, cols = len(grid), len(grid[0])
    islands = 0
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] != "1":
                continue
            islands += 1
            grid[r][c] = "0"
            queue = deque([(r, c)])
            while queue:
                y, x = queue.popleft()
                for ny, nx in ((y + 1, x), (y - 1, x), (y, x + 1), (y, x - 1)):
                    if 0 <= ny < rows and 0 <= nx < cols and grid[ny][nx] == "1":
                        grid[ny][nx] = "0"
                        queue.append((ny, nx))
    return islands

O(rows x cols). A recursive DFS hits RecursionError once an island has more than about 1,000 cells in a chain. Raising the limit with sys.setrecursionlimit only moves the crash.

Advanced Challenges

Senior rounds add design, decorators, heaps and graphs. The follow-ups (thread safety, memory, what breaks at scale) matter more than the first draft.

17. Build an LRU cache with O(1) get and put.

Task: get returns -1 for a missing key; put evicts the least recently used key when the cache is full.

from collections import OrderedDict

class LRUCache:
    def __init__(self, capacity: int) -> None:
        self.capacity = capacity
        self.data: OrderedDict[int, int] = OrderedDict()

    def get(self, key: int) -> int:
        if key not in self.data:
            return -1
        self.data.move_to_end(key)
        return self.data[key]

    def put(self, key: int, value: int) -> None:
        self.data[key] = value
        self.data.move_to_end(key)
        if len(self.data) > self.capacity:
            self.data.popitem(last=False)

move_to_end and popitem(last=False) are both O(1). To cache a function's results, functools.lru_cache(maxsize=...) already does this. The class is not thread-safe; ask how the candidate would add a lock.

18. Write a retry decorator that takes arguments.

Task: @retry(times=3, exceptions=(ConnectionError,)) retries a failing call with growing delays, then re-raises the last error.

import functools
import time

def retry(times: int = 3, delay: float = 0.1, exceptions=(Exception,)):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions:
                    if attempt == times:
                        raise
                    time.sleep(delay * 2 ** (attempt - 1))
        return wrapper
    return decorator

@retry(times=3, exceptions=(ConnectionError,))
def fetch_price(symbol: str) -> float: ...

A decorator with arguments is a function that returns a decorator, hence three levels. functools.wraps keeps the wrapped function's name and docstring. A bare raise re-raises the original error with its traceback.

19. Merge k sorted linked lists.

Task: merge 1-4-5, 1-3-4 and 2-6 into 1-1-2-3-4-4-5-6.

import heapq

def merge_k_lists(lists: list[ListNode | None]) -> ListNode | None:
    heap = [(node.val, i, node) for i, node in enumerate(lists) if node]
    heapq.heapify(heap)
    dummy = tail = ListNode(0)
    while heap:
        _, i, node = heapq.heappop(heap)
        tail.next = node
        tail = node
        if node.next:
            heapq.heappush(heap, (node.next.val, i, node.next))
    return dummy.next

O(N log k) for N nodes in k lists. The index i is the trap: without it, two equal values make Python compare two ListNode objects, which raises TypeError. For plain sorted lists, heapq.merge(*lists) does the whole job.

20. Keep the running median of a stream.

Task: after adding 1 and 2 the median is 1.5; after adding 3 it is 2.

import heapq

class MedianFinder:
    def __init__(self) -> None:
        self.low: list[int] = []    # max-heap, stored negated
        self.high: list[int] = []   # min-heap

    def add(self, num: int) -> None:
        heapq.heappush(self.low, -num)
        heapq.heappush(self.high, -heapq.heappop(self.low))
        if len(self.high) > len(self.low):
            heapq.heappush(self.low, -heapq.heappop(self.high))

    def median(self) -> float:
        if len(self.low) > len(self.high):
            return float(-self.low[0])
        return (-self.low[0] + self.high[0]) / 2

add is O(log n) and median is O(1). Before 3.14, heapq only had min-heap functions, so the lower half is stored negated. Challenge 32 shows the 3.14 version.

21. Find the fewest coins that make an amount.

Task: coins [1, 2, 5] and amount 11 return 3. If no combination works, return -1.

def coin_change(coins: list[int], amount: int) -> int:
    dp = [0] + [float("inf")] * amount
    for a in range(1, amount + 1):
        for coin in coins:
            if coin <= a:
                dp[a] = min(dp[a], dp[a - coin] + 1)
    return -1 if dp[amount] == float("inf") else int(dp[amount])

O(amount x coins). Greedy fails for coins [1, 3, 4] and amount 6: it picks 4 + 1 + 1, but 3 + 3 uses two coins.

22. Can a string be split into dictionary words?

Task: "leetcode" with ["leet", "code"] returns True; "catsandog" with ["cats", "dog", "sand", "and", "cat"] returns False.

from functools import cache

def word_break(s: str, words: list[str]) -> bool:
    vocab = set(words)

    @cache
    def can_split(i: int) -> bool:
        if i == len(s):
            return True
        return any(s[i:j] in vocab and can_split(j) for j in range(i + 1, len(s) + 1))

    return can_split(0)

functools.cache turns the recursive search into top-down dynamic programming, O(n2) substring checks. Without it the same suffixes are solved again and again, which is exponential.

The recursion depth equals the string length, so a bottom-up table suits very long inputs.

23. Find the shortest distance from one node to every other.

Task: edge weights are positive. From A, with A to B = 4, A to C = 1, C to B = 2, C to D = 5 and B to D = 1, return A 0, C 1, B 3, D 4.

import heapq

def shortest_paths(graph: dict[str, list[tuple[str, int]]], start: str) -> dict[str, int]:
    dist = {start: 0}
    heap = [(0, start)]
    while heap:
        d, node = heapq.heappop(heap)
        if d > dist[node]:
            continue                      # stale entry, already improved
        for nxt, weight in graph.get(node, []):
            nd = d + weight
            if nd < dist.get(nxt, float("inf")):
                dist[nxt] = nd
                heapq.heappush(heap, (nd, nxt))
    return dist

Dijkstra's algorithm, O((V + E) log V). heapq cannot lower a key in place, so the code pushes a new entry and skips stale ones when they come out. Negative weights break it; Bellman-Ford handles those.

Six numbered heap pops of Dijkstra's algorithm from node A: A, then C, then B at distance 3, a stale B at 4 that is skipped, D at 4, and a stale D at 6, ending with A 0, C 1, B 3, D 4.

24. Order build steps so every dependency comes first.

Task: app needs lib and config, and lib needs config. Return a valid order, or None if there is a cycle.

from graphlib import CycleError, TopologicalSorter

def build_order(deps: dict[str, set[str]]) -> list[str] | None:
    try:
        return list(TopologicalSorter(deps).static_order())
    except CycleError:
        return None

# build_order({"app": {"lib", "config"}, "lib": {"config"}})
# returns ['config', 'lib', 'app']

graphlib has been in the standard library since Python 3.9. A strong candidate uses it, then writes Kahn's algorithm by hand when asked: count incoming edges, and repeatedly take nodes whose count is zero.

25. Build a sliding-window rate limiter.

Task: allow at most limit calls in any window seconds. With limit 2 and a 1-second window, calls at 0.0, 0.1 and 0.2 return True, True, False, and a call at 1.05 returns True.

import time
from collections import deque

class RateLimiter:
    def __init__(self, limit: int, window: float) -> None:
        self.limit = limit
        self.window = window
        self.calls: deque[float] = deque()

    def allow(self, now: float | None = None) -> bool:
        now = time.monotonic() if now is None else now
        while self.calls and now - self.calls[0] >= self.window:
            self.calls.popleft()
        if len(self.calls) < self.limit:
            self.calls.append(now)
            return True
        return False

Each call is amortized O(1), and memory is O(limit). time.monotonic() matters: time.time() can jump when the system clock changes. Passing now in makes the class testable without sleeping.

What Changed Recently

These challenges need Python 3.13 or 3.14. Tell candidates which version the editor runs before you start.

Oct 7, 2024
Python 3.13: copy.replace(), batched(strict=True), experimental free-threaded build
Oct 7, 2025
Python 3.14: t-strings, bracketless except, heapq max-heaps, free-threading officially supported

26. Split a list into chunks, and fail if the last one is short.

Task: [1, 2, 3, 4, 5] in chunks of 2 gives [(1, 2), (3, 4), (5,)]. In strict mode, the short last chunk must raise an error.

from itertools import batched

def chunks(items: list[int], size: int, strict: bool = False) -> list[tuple[int, ...]]:
    return list(batched(items, size, strict=strict))

# chunks([1, 2, 3, 4, 5], 2)               [(1, 2), (3, 4), (5,)]
# chunks([1, 2, 3, 4, 5], 2, strict=True)  raises ValueError

itertools.batched arrived in 3.12, and the strict flag came in Python 3.13. Batches are tuples. The older idiom, [items[i:i + n] for i in range(0, len(items), n)], only works on sequences, while batched takes any iterable.

27. Return an updated copy of a frozen dataclass.

Task: an Order is immutable. Write mark_paid so it returns a new order with status "paid" and leaves the original alone.

import copy
from dataclasses import dataclass

@dataclass(frozen=True)
class Order:
    id: int
    status: str
    total: float

def mark_paid(order: Order) -> Order:
    return copy.replace(order, status="paid")

# mark_paid(Order(1, "new", 9.5)) returns Order(id=1, status='paid', total=9.5)

copy.replace() is new in Python 3.13. It works the same way on dataclasses, named tuples, datetime objects and SimpleNamespace.

Any class can support it by defining __replace__(), so callers no longer need dataclasses.replace for one type and _replace for another.

28. Parse a port number and catch two error types without brackets.

Task: return the port as an int if it is between 1 and 65535, otherwise None. "8080" returns 8080; "http", "70000" and None return None.

def parse_port(raw: str | None) -> int | None:
    try:
        port = int(raw)
        if not 0 < port < 65536:
            raise ValueError(port)
        return port
    except ValueError, TypeError:
        return None

PEP 758, new in 3.14, lets except list several types without brackets. Brackets are still required with as: except (ValueError, TypeError) as e. In Python 2, except A, B meant "catch A and bind it to the name B".

The PEP allows the form again without bringing back that meaning.

29. Write an HTML renderer that escapes user input with a t-string.

Task: html(t"<p>Hello, {name}</p>") must escape name but not the literal markup around it.

from html import escape
from string.templatelib import Interpolation, Template

def html(template: Template) -> str:
    parts = []
    for part in template:
        if isinstance(part, Interpolation):
            parts.append(escape(str(part.value)))
        else:
            parts.append(part)
    return "".join(parts)

name = "<script>alert(1)</script>"
print(html(t"<p>Hello, {name}</p>"))
# <p>Hello, &lt;script&gt;alert(1)&lt;/script&gt;</p>

Template strings (PEP 750, Python 3.14) look like f-strings but return a Template object, not a string. Iterating over it yields the literal parts as strings and each {...} as an Interpolation.

That split lets a library escape only user values. This version ignores !r conversions and format specs; ask the candidate how they would handle them.

30. Count primes in parallel with threads, and say when it gets faster.

Task: split the range below 200,000 across four threads and count the primes. The answer is 17,984.

import sys
from concurrent.futures import ThreadPoolExecutor

def count_primes(lo: int, hi: int) -> int:
    return sum(
        1 for n in range(max(lo, 2), hi)
        if all(n % d for d in range(2, int(n ** 0.5) + 1))
    )

def parallel_count(limit: int, workers: int = 4) -> int:
    step = limit // workers + 1
    ranges = [(lo, min(lo + step, limit)) for lo in range(0, limit, step)]
    with ThreadPoolExecutor(workers) as pool:
        return sum(pool.map(lambda r: count_primes(*r), ranges))

print(parallel_count(200_000), "GIL enabled:", sys._is_gil_enabled())

On the standard build this is no faster than one thread, because the GIL lets only one thread run Python code at a time. On the free-threaded build (python3.14t), the threads run on separate cores.

PEP 779 made that build officially supported in 3.14, though it is still not the default. sys._is_gil_enabled(), added in 3.13, reports which mode is running.

Hiring for backend work? These challenges test algorithms and the standard library. Our Python backend interview questions cover the other half: async, the GIL, ORMs, APIs and testing.

Signs of a Strong Answer

  • They reach for Counter, defaultdict, deque, heapq and bisect first, and can write the manual version when asked.
  • They use deque.popleft() for queues and can say why list.pop(0) is O(n).
  • They add a tie-breaker to heap tuples before the TypeError happens.
  • They pick BFS or an explicit stack on large inputs because of the 1,000-frame recursion limit.
  • They state which Python version a feature needs, such as copy.replace (3.13) or t-strings (3.14).
  • They know threads speed up CPU-bound Python only on the free-threaded build.

Hiring Python Developers

Second Talent places pre-vetted Python developers from Asia, screened with live coding challenges like these. See typical rates in our Python developer cost guide.

Tell us the stack and we send a shortlist within 24 hours. Start hiring, or see our Django and FastAPI interview guides.

Ready to hire? Hire Python Developers: vetted AI-native talent, with first profiles in 24 hours.

Get developers who ship with AI agents.

Starts at $23/Hour.

Start Hiring

$0 cost until you hire