Prog Academy
RU UA
Вопросы на собеседовании Python-разработчика: 36 вопросов с ответами
Карьера в IT

Вопросы на собеседовании Python-разработчика: 36 вопросов с ответами

На собеседовании Python Junior проверяют четыре вещи: понимаете ли вы, как устроены типы данных и функции, владеете ли ООП и стандартными инструментами языка, можете ли написать запрос к базе данных и тест, и способны ли рассуждать вслух над небольшой задачей. Ниже — 36 вопросов с короткими ответами, сгруппированные по семи темам, с кодом, который можно запустить и проверить самому, а также практические задачи, типичные ошибки кандидатов и план подготовки на две недели.

Я преподаю программирование больше 12 лет и веду в Prog Academy курсы по Java и Python. Перед собеседованиями мы со студентами разбираем именно такие вопросы: на них быстро видно, понимает человек механику языка или выучил определение. Список — мой преподавательский отбор, а не рейтинг по частоте: в конкретной компании набор вопросов будет своим, но темы повторяются.

Актуально на октябрь 2026 года

Примеры кода запущены на Python 3.12.2; тесты pytest 8.3.2 и пример с ORM на Django 4.2.1 — на Python 3.9.6. Вывод в комментариях — реальный. Описанное поведение одинаково во всех поддерживаемых версиях Python 3; там, где версия важна, она названа. Фрагмент FastAPI приведён по официальной документации. Отбор вопросов — авторский, это не статистика собеседований.

Как проходит собеседование Python-разработчика?

Обычно отбор состоит из четырёх этапов: короткий разговор с рекрутером, техническое интервью, практическое задание и финальная встреча с руководителем или командой. Состав и порядок этапов зависят от компании: где-то тестовое задание дают до технического интервью, где-то его заменяет live coding. Уточняйте процесс у рекрутера на первом же созвоне.

ЭтапЧто проверяютКак готовиться
Скрининг с рекрутеромСоответствие вакансии, мотивацию, ожидания по зарплате и формату работы, английскийРассказ о себе на две минуты, понятный ответ «почему Python и почему эта компания», заранее продуманная вилка
Техническое интервьюЗнание языка, SQL, основ веба и тестирования; умение объяснятьПроговорить вслух ответы на вопросы из этой статьи, каждый пример запустить самому
Практическое заданиеУмение написать работающий код: live coding или тестовое задание на домРешать небольшие задачи с таймером и вслух; для тестового — README, тесты, чистая история коммитов
Финальная встречаКак вы работаете в команде, реагируете на замечания, чего хотите от работыПримеры из учёбы и проектов: что не получалось и как вы это решили; вопросы к команде

Про зарплатные ожидания лучше говорить с цифрами под рукой: медианные зарплаты по уровням собраны на странице зарплаты Python-разработчиков. Как выглядит путь до первого собеседования целиком, разобрано в статье как стать Python-разработчиком с нуля.

Какие темы проверяют на Junior и Middle?

На уровне Junior проверяют основы языка, ООП, SQL и умение написать простой тест; на Middle к этому добавляются конкурентность, производительность и архитектурные решения. Граница условна: в таблице указан уровень, начиная с которого тему, по моему опыту, спрашивают всерьёз.

ТемаЧто проверяютУровень
Типы данных и изменяемостьИзменяемые и неизменяемые типы, == и is, копирование, хешируемостьJunior
ФункцииПередача аргументов, *args и **kwargs, значения по умолчанию, замыкания, области видимостиJunior
ООПКлассы и экземпляры, dunder-методы, наследование, super(), dataclassJunior; MRO и __hash__ — ближе к Middle
Итераторы и генераторыПротокол итерации, yield, включения, ленивые вычисленияJunior
Декораторы и контекстные менеджерыКак устроены, зачем functools.wraps, withJunior; с параметрами — Middle
Исключения и окружениеtry/except/else/finally, свои исключения, venv, аннотации типовJunior
КонкурентностьGIL, потоки, процессы, asyncioJunior — общее понимание; Middle — подробно
SQL и ORMJOIN, GROUP BY, параметризованные запросы, проблема N+1Junior; оптимизация запросов — Middle
ВебHTTP-методы и коды ответа, REST, Django и FastAPI на уровне концепцийJunior
Тестированиеpytest: тесты, фикстуры, параметризацияJunior

Типы данных и изменяемость

С этого блока начинается почти любое техническое интервью по Python: изменяемость объясняет половину «странного» поведения языка. Первоисточник — раздел Data model документации.

Какие типы данных в Python изменяемые, а какие нет?

Неизменяемые — int, float, bool, str, tuple, frozenset, bytes; изменяемые — list, dict, set, bytearray и по умолчанию экземпляры ваших классов. Изменяемый объект можно поменять на месте, и это увидят все переменные, которые на него ссылаются. «Изменение» неизменяемого объекта на самом деле создаёт новый.

a = [1, 2]
b = a            # второе имя того же списка
b.append(3)
print(a)         # [1, 2, 3]

s = "py"
t = s
t += "thon"      # создаётся новая строка
print(s, t)      # py python

Что хотят услышать: переменная в Python — это имя, привязанное к объекту, а не «коробка со значением». Присваивание ничего не копирует.

Чем отличается == от is?

== сравнивает значения (вызывает метод __eq__), is проверяет, что это один и тот же объект в памяти. Два разных списка с одинаковым содержимым равны, но не идентичны.

a = [1, 2]
b = [1, 2]
c = a
print(a == b)    # True
print(a is b)    # False
print(a is c)    # True

is используют для сравнения с None: if value is None. Типичная ошибка — сравнивать через is числа и строки: иногда это «работает», потому что CPython переиспользует некоторые объекты, но это деталь реализации, на которую нельзя полагаться.

Чем список отличается от кортежа?

Список изменяемый, кортеж — нет: после создания в кортеже нельзя заменить, добавить или удалить элемент. Поэтому кортеж можно использовать как ключ словаря (если все его элементы хешируемы), а список нельзя. По смыслу список — это набор однородных элементов переменной длины, кортеж — запись фиксированной структуры, например координаты.

point = (1, [2, 3])
point[1].append(4)     # сам кортеж не меняется, меняется список внутри него
print(point)           # (1, [2, 3, 4])
point[0] = 10          # TypeError: 'tuple' object does not support item assignment

Что хотят услышать: неизменяемость кортежа касается только ссылок, которые он хранит. Кортеж со списком внутри остаётся кортежем, но перестаёт быть хешируемым.

Что может быть ключом словаря?

Ключом может быть любой хешируемый объект: у него есть хеш, который не меняется в течение жизни объекта, и его можно сравнить с другими через ==. Это числа, строки, кортежи из хешируемых элементов, frozenset. Списки, словари и множества ключами быть не могут.

prices = {("Kyiv", "Lviv"): 540}
print(prices[("Kyiv", "Lviv")])    # 540

prices = {["Kyiv", "Lviv"]: 540}   # TypeError: unhashable type: 'list'

Те же требования у элементов множества. Требование вытекает из устройства словаря: это хеш-таблица, и позиция элемента вычисляется по хешу ключа.

Чем поверхностная копия отличается от глубокой?

Поверхностная копия создаёт новый контейнер, но кладёт в него ссылки на те же вложенные объекты; глубокая рекурсивно копирует и вложенные объекты. Разница заметна только при вложенных изменяемых структурах. Подробности — в документации модуля copy.

import copy

original = [[1, 2], [3]]
shallow = copy.copy(original)       # то же, что original.copy() или original[:]
deep = copy.deepcopy(original)

original[0].append(99)
print(shallow)   # [[1, 2, 99], [3]]
print(deep)      # [[1, 2], [3]]

Сохраняет ли словарь порядок элементов и сколько стоит поиск по ключу?

Да: начиная с Python 3.7 словарь гарантированно хранит ключи в порядке добавления — это записано в документации dict. Поиск, вставка и удаление по ключу в словаре и множестве в среднем выполняются за O(1), а проверка x in some_list — за O(n), потому что список приходится просматривать целиком.

d = {"b": 1, "a": 2}
d["c"] = 3
print(list(d))                                  # ['b', 'a', 'c']
print({"a": 1, "b": 2} == {"b": 2, "a": 1})     # True — порядок не влияет на равенство

Множество порядок не гарантирует. Практический вывод: если в цикле вы много раз проверяете «есть ли элемент», превратите список в множество.

Функции, аргументы и замыкания

В этом блоке проверяют, понимаете ли вы, что происходит с объектом при передаче в функцию и когда вычисляются значения по умолчанию.

Как Python передаёт аргументы в функцию: по ссылке или по значению?

Ни то ни другое в чистом виде: аргументы передаются присваиванием. Параметр функции становится ещё одним именем того же объекта. Если объект изменяемый и функция меняет его на месте, это видно снаружи; если функция привязывает параметр к новому объекту, вызывающий код этого не заметит. Формулировка взята из официального FAQ.

def add_item(items):
    items.append(1)      # меняет объект вызывающего кода

def rebind(items):
    items = [0]          # перепривязывает только локальное имя

data = []
add_item(data)
rebind(data)
print(data)              # [1]

Что такое *args и **kwargs?

*args собирает лишние позиционные аргументы в кортеж, **kwargs — лишние именованные в словарь. Имена args и kwargs — соглашение, работают звёздочки. При вызове те же звёздочки делают обратное: распаковывают последовательность и словарь в аргументы.

def report(title, *args, sep=", ", **kwargs):
    print(title, args, sep, kwargs)

report("Sales", 10, 20, sep="; ", debug=True)
# Sales (10, 20) ;  {'debug': True}

nums = [1, 2, 3]
options = {"sep": "-"}
print(*nums, **options)      # 1-2-3

Параметры после *args (здесь sep) можно передать только по имени. Чаще всего *args, **kwargs встречаются в декораторах — чтобы обёртка принимала любые аргументы.

Почему нельзя использовать изменяемый объект как значение аргумента по умолчанию?

Потому что значение по умолчанию вычисляется один раз — в момент определения функции, а не при каждом вызове. Список, заданный по умолчанию, становится общим для всех вызовов и накапливает данные. Это описано в учебнике Python.

def add(item, bucket=[]):
    bucket.append(item)
    return bucket

print(add(1))    # [1]
print(add(2))    # [1, 2] — тот же список, что и в первом вызове

def add_fixed(item, bucket=None):
    if bucket is None:
        bucket = []
    bucket.append(item)
    return bucket

print(add_fixed(1))    # [1]
print(add_fixed(2))    # [2]

Что хотят услышать: причину (момент вычисления) и стандартное решение — None по умолчанию и создание объекта внутри функции.

Что такое замыкание?

Замыкание — это вложенная функция, которая запоминает переменные из внешней функции и продолжает ими пользоваться после того, как внешняя функция завершилась. На замыканиях построены декораторы и фабрики функций.

def make_counter():
    count = 0

    def counter():
        nonlocal count
        count += 1
        return count

    return counter

counter = make_counter()
print(counter(), counter())    # 1 2

Типичная ловушка — позднее связывание: замыкание хранит не значение переменной, а саму переменную, и читает её в момент вызова.

funcs = [lambda: i for i in range(3)]
print([f() for f in funcs])    # [2, 2, 2]

funcs = [lambda i=i: i for i in range(3)]   # значение фиксируется как аргумент по умолчанию
print([f() for f in funcs])    # [0, 1, 2]

Как Python ищет переменные и зачем нужны global и nonlocal?

Имя ищется по правилу LEGB: сначала в локальной области функции (Local), затем во внешних функциях (Enclosing), затем в модуле (Global), затем среди встроенных имён (Built-in). Читать внешнюю переменную можно свободно, но любое присваивание внутри функции делает имя локальным для всей функции. global и nonlocal говорят интерпретатору, что присваивать нужно переменной модуля или внешней функции.

total = 0

def broken_inc():
    total += 1       # UnboundLocalError: имя total считается локальным

def inc():
    global total
    total += 1

inc()
print(total)         # 1

Типичная ошибка: решать задачу через global. На собеседовании лучше сказать, что изменяемое глобальное состояние усложняет тестирование, и передать значение аргументом.

ООП: классы, dunder-методы, наследование

Здесь проверяют не определения инкапсуляции и полиморфизма, а то, как объектная модель работает в самом Python.

Чем атрибут класса отличается от атрибута экземпляра?

Атрибут класса хранится в самом классе и общий для всех экземпляров; атрибут экземпляра создаётся (обычно в __init__) для каждого объекта отдельно. При чтении obj.attr Python сначала ищет атрибут в экземпляре, потом в классе и его родителях.

class Team:
    members = []                 # общий список для всех экземпляров

    def __init__(self, name):
        self.name = name         # свой у каждого экземпляра

a = Team("backend")
b = Team("frontend")
a.members.append("Anna")
print(b.members)                 # ['Anna']

Это та же ловушка, что и с изменяемым аргументом по умолчанию. Исправление — создавать список в __init__: self.members = [].

Что такое dunder-методы и чем __str__ отличается от __repr__?

Dunder-методы (double underscore, «магические») — это методы вида __init__, __len__, __eq__, которые Python вызывает сам в ответ на синтаксис языка: создание объекта, len(x), x == y, x + y. __repr__ — однозначное представление для разработчика, __str__ — читаемое для пользователя; если __str__ не определён, используется __repr__.

class Money:
    def __init__(self, amount, currency):
        self.amount = amount
        self.currency = currency

    def __repr__(self):
        return f"Money({self.amount!r}, {self.currency!r})"

    def __str__(self):
        return f"{self.amount} {self.currency}"

    def __add__(self, other):
        return Money(self.amount + other.amount, self.currency)

price = Money(10, "USD")
print(price)                      # 10 USD
print([price])                    # [Money(10, 'USD')] — контейнеры показывают repr
print(price + Money(5, "USD"))    # 15 USD

Что такое MRO и как работает super()?

MRO (method resolution order) — порядок, в котором Python обходит классы при поиске метода. Он строится алгоритмом C3 и доступен в атрибуте __mro__. super() вызывает метод не «родителя», а следующего класса в MRO — при множественном наследовании это может быть «сосед», а не предок. Алгоритм описан в документации.

class A:
    def hello(self):
        return "A"

class B(A):
    def hello(self):
        return "B > " + super().hello()

class C(A):
    def hello(self):
        return "C > " + super().hello()

class D(B, C):
    pass

print(D().hello())                           # B > C > A
print([cls.__name__ for cls in D.__mro__])   # ['D', 'B', 'C', 'A', 'object']

Что хотят услышать: из B вызов super().hello() попал в C, хотя B от C не наследуется, — потому что порядок определяется MRO класса объекта, то есть D.

Чем отличаются @staticmethod, @classmethod и @property?

Обычный метод получает первым аргументом экземпляр (self). @classmethod получает класс (cls) и чаще всего служит альтернативным конструктором. @staticmethod не получает ни того ни другого — это обычная функция, которая лежит в классе по смыслу. @property превращает метод в вычисляемый атрибут, к которому обращаются без скобок.

class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    @property
    def fahrenheit(self):
        return self.celsius * 9 / 5 + 32

    @classmethod
    def from_fahrenheit(cls, value):
        return cls((value - 32) * 5 / 9)

    @staticmethod
    def is_valid(celsius):
        return celsius >= -273.15

t = Temperature.from_fahrenheit(212)
print(t.celsius, t.fahrenheit)       # 100.0 212.0
print(Temperature.is_valid(-300))    # False

Свойство без сеттера доступно только для чтения: присваивание t.fahrenheit = 1 вызовет AttributeError.

Что такое dataclass и что он генерирует?

Декоратор @dataclass по аннотациям полей генерирует __init__, __repr__ и __eq__, избавляя от шаблонного кода в классах, которые в основном хранят данные. Параметр frozen=True запрещает менять поля после создания и делает объект хешируемым, order=True добавляет методы сравнения. Полный список — в документации dataclasses.

from dataclasses import dataclass, field

@dataclass(frozen=True)
class Point:
    x: int
    y: int = 0

@dataclass
class Order:
    id: int
    items: list[str] = field(default_factory=list)

print(Point(1))                  # Point(x=1, y=0)
print(Point(1) == Point(1, 0))   # True
print(Order(7))                  # Order(id=7, items=[])

Изменяемое значение по умолчанию dataclass задать не даст: items: list = [] вызывает ValueError и подсказывает использовать default_factory — защита от ловушки из блока о функциях.

Что произойдёт, если определить __eq__, но не определить __hash__?

Экземпляры класса перестанут быть хешируемыми: Python автоматически установит __hash__ в None, и объект нельзя будет положить в множество или сделать ключом словаря. Так язык защищает правило: равные объекты обязаны иметь одинаковый хеш. Оно описано в разделе о __hash__.

class User:
    def __init__(self, email):
        self.email = email

    def __eq__(self, other):
        return isinstance(other, User) and self.email == other.email

print(User("[email protected]") == User("[email protected]"))   # True
users = {User("[email protected]")}                   # TypeError: unhashable type: 'User'

Чтобы вернуть хешируемость, определите __hash__ по тем же полям, что участвуют в сравнении: return hash(self.email). Эти поля после этого не должны меняться. Вопрос ближе к уровню Middle.

Итераторы, генераторы, декораторы, контекстные менеджеры

Это инструменты, по которым отличают человека, писавшего на Python, от человека, читавшего о нём: их используют в каждом рабочем проекте.

Чем итерируемый объект отличается от итератора?

Итерируемый объект (iterable) умеет отдать итератор — у него есть метод __iter__. Итератор — это объект с методом __next__, который выдаёт следующий элемент и бросает StopIteration, когда элементы закончились. Список — итерируемый, но не итератор; по нему можно пройти много раз, а итератор одноразовый. Протокол описан в документации.

numbers = [1, 2]
iterator = iter(numbers)
print(next(iterator), next(iterator))   # 1 2
next(iterator)                          # StopIteration

print(iter(iterator) is iterator)       # True — итератор возвращает сам себя
print(iter(numbers) is numbers)         # False — список каждый раз создаёт новый итератор

Цикл for делает именно это: вызывает iter(), затем next() до StopIteration.

Что такое генератор и чем yield отличается от return?

Генератор — это функция с yield: при вызове она не выполняется, а возвращает итератор. return завершает функцию, а yield отдаёт значение и приостанавливает её, сохраняя локальные переменные до следующего next(). Значения вычисляются по одному, поэтому генератор не держит всю последовательность в памяти.

def countdown(n):
    while n > 0:
        yield n
        n -= 1

gen = countdown(3)
print(next(gen))    # 3
print(list(gen))    # [2, 1]
print(list(gen))    # [] — генератор исчерпан

Что хотят услышать: где это полезно — чтение большого файла построчно, постраничная выгрузка из API, конвейеры обработки данных.

Чем списковое включение отличается от генераторного выражения?

Списковое включение [...] сразу создаёт весь список в памяти; генераторное выражение (...) создаёт ленивый итератор, который вычисляет элементы по мере запроса и проходится один раз. Синтаксис отличается только скобками.

import sys

squares_list = [x * x for x in range(1_000_000)]
squares_gen = (x * x for x in range(1_000_000))

print(sys.getsizeof(squares_list) > sys.getsizeof(squares_gen))   # True
print(sum(squares_gen))    # 333332833333500000
print(sum(squares_gen))    # 0 — второй раз пройти нельзя

words = ["api", "orm", "api"]
print({w: len(w) for w in words})    # {'api': 3, 'orm': 3} — включение словаря

Если результат нужен один раз — для sum, any, max — берите генераторное выражение. Если по нему нужно ходить несколько раз или обращаться по индексу — список.

Что такое декоратор и зачем нужен functools.wraps?

Декоратор — это функция, которая принимает функцию и возвращает новую, обычно обёртку с дополнительным поведением: логированием, проверкой прав, кешированием, повтором при ошибке. Запись @log_calls над функцией равносильна add = log_calls(add). functools.wraps копирует в обёртку имя и документацию исходной функции — без него add.__name__ вернул бы wrapper.

import functools

def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        print(f"{func.__name__}{args} -> {result}")
        return result
    return wrapper

@log_calls
def add(a, b):
    """Return the sum of two numbers."""
    return a + b

add(2, 3)              # add(2, 3) -> 5
print(add.__name__)    # add

Декоратор с параметрами — это ещё один уровень вложенности: функция, которая возвращает декоратор.

def repeat(times):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(times):
                func(*args, **kwargs)
        return wrapper
    return decorator

@repeat(times=2)
def ping():
    print("ping")

ping()    # ping выводится дважды

Что такое контекстный менеджер и как работает with?

Контекстный менеджер — объект с методами __enter__ и __exit__. Оператор with вызывает __enter__ на входе в блок и гарантированно вызывает __exit__ на выходе, даже если внутри возникло исключение. Так закрывают файлы, соединения и блокировки. Проще всего написать свой через contextlib.contextmanager.

from contextlib import contextmanager

class Connection:
    def __enter__(self):
        print("open")
        return self

    def __exit__(self, exc_type, exc, traceback):
        print("close")
        return False          # не подавлять исключение

@contextmanager
def connection():
    print("open")
    try:
        yield
    finally:
        print("close")

with Connection():
    print("work")
# open
# work
# close

Обе формы равноценны: во втором варианте код до yield играет роль __enter__, а блок finally — роль __exit__.

PY

Эти темы — в программе курса Python

Изменяемые и неизменяемые типы, *args и **kwargs, итераторы и генераторы, декораторы, исключения, ООП и наследование, SQL и Django входят в модули курса Python в Prog Academy: AI Start, Python Start, Python для Data Analytics, Python + Django и SQL. Курс длится 4,5 месяца и заканчивается дипломным проектом для портфолио.

Программа курса Python →

Исключения, модули, окружение и типизация

Блок о том, как код ведёт себя при ошибках и как устроен проект вокруг кода: модули, зависимости, аннотации типов.

Как работают try, except, else и finally?

try содержит код, который может упасть; except обрабатывает указанный тип исключения; else выполняется, только если исключения не было; finally выполняется всегда — и после ошибки, и после return. Подробно — в главе Errors and Exceptions учебника.

def parse(value):
    try:
        number = int(value)
    except ValueError as error:
        print("error:", error)
        return None
    else:
        print("parsed")
        return number
    finally:
        print("finally")

print(parse("42"))
# parsed
# finally
# 42
print(parse("abc"))
# error: invalid literal for int() with base 10: 'abc'
# finally
# None

Что хотят услышать: зачем нужен else — чтобы в try оставался только тот код, ошибку которого вы действительно хотите перехватить.

Почему нельзя писать голый except и как создать своё исключение?

Голый except: перехватывает всё, включая KeyboardInterrupt и SystemExit, которые наследуются от BaseException, а не от Exception: программу становится невозможно остановить, а настоящие ошибки прячутся. Перехватывайте конкретные типы. Своё исключение — это класс, унаследованный от Exception; иерархия встроенных исключений есть в документации.

class InsufficientFundsError(Exception):
    def __init__(self, balance, amount):
        super().__init__(f"balance {balance}, requested {amount}")
        self.balance = balance
        self.amount = amount

def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFundsError(balance, amount)
    return balance - amount

try:
    withdraw(100, 250)
except InsufficientFundsError as error:
    print(error, "| missing:", error.amount - error.balance)
# balance 100, requested 250 | missing: 150

Типичная ошибка: except Exception: pass. Проглоченное исключение хуже упавшей программы: ошибка есть, а следов нет.

Что делает конструкция if __name__ == "__main__"?

Она отделяет код, который должен выполняться только при запуске файла как программы, от кода, который выполняется при импорте. Когда файл запускают напрямую, переменная __name__ равна "__main__"; когда его импортируют — имени модуля. См. документацию __main__.

def main():
    print("run as script")


if __name__ == "__main__":
    main()

При запуске python m.py функция main() выполнится, при import m — нет. Без этой проверки импорт модуля в тестах запускал бы всю программу.

Зачем нужно виртуальное окружение и чем venv отличается от pip?

Виртуальное окружение — это изолированный каталог со своим интерпретатором и своими пакетами, чтобы зависимости одного проекта не конфликтовали с другим и с системным Python. venv создаёт окружение, pip устанавливает в него пакеты — это разные инструменты, которые работают в паре. Модуль venv входит в стандартную библиотеку.

python3 -m venv .venv
source .venv/bin/activate            # Windows: .venv\Scripts\activate
python -m pip install -r requirements.txt
python -m pip freeze > requirements.txt

Каталог .venv в Git не коммитят: в репозитории лежит список зависимостей (requirements.txt или pyproject.toml), по которому окружение воссоздаётся на любой машине. Основы работы с репозиторием — в статье Git для начинающих.

Что такое аннотации типов и проверяет ли их Python во время выполнения?

Аннотации — это подсказки о типах параметров и результата функции. Сам интерпретатор их не проверяет: в документации typing прямо сказано, что среда выполнения не применяет аннотации. Их читают статические анализаторы (mypy, pyright), редактор кода и библиотеки вроде Pydantic и FastAPI.

def find_user(user_id: int) -> dict[str, str] | None:
    users = {1: {"name": "Anna"}}
    return users.get(user_id)

print(find_user(1))        # {'name': 'Anna'}
print(find_user("abc"))    # None — строка вместо int, но ошибки нет

Запись X | None доступна с Python 3.10, а dict[str, str] без импорта из typing — с 3.9. Что хотят услышать: Python остаётся языком с динамической типизацией, аннотации — инструмент для людей и анализаторов.

GIL, потоки, процессы и asyncio

От Junior здесь ждут общего понимания — какой инструмент для какой задачи; подробности про синхронизацию и устройство цикла событий спрашивают уже у Middle.

Что такое GIL?

GIL (global interpreter lock) — это блокировка в интерпретаторе CPython, из-за которой в каждый момент времени байт-код Python выполняет только один поток. Она упрощает управление памятью, но не даёт потокам ускорять вычисления на нескольких ядрах. На время блокирующего ввода-вывода (сеть, диск) поток GIL отпускает, поэтому для таких задач потоки полезны. Определение — в глоссарии документации.

Важное уточнение для 2026 года: начиная с Python 3.13 существует отдельная сборка CPython без GIL (free-threaded), в 3.14 она стала официально поддерживаемой, но остаётся опциональной — обычная сборка по-прежнему работает с GIL. Подробности — в руководстве Python support for free threading.

Типичная ошибка: «из-за GIL в Python нет многопоточности». Потоки есть и работают; ограничение касается параллельного выполнения Python-кода на нескольких ядрах.

Чем отличаются threading, multiprocessing и asyncio?

Потоки и asyncio подходят для задач, которые в основном ждут ввода-вывода; процессы — для тяжёлых вычислений. Потоки переключает операционная система, корутины asyncio переключаются сами в точках await внутри одного потока, а у каждого процесса свой интерпретатор и своя память, поэтому GIL им не мешает.

ИнструментДля каких задачОсобенности
threadingВвод-вывод: запросы к API, файлы, база данныхОбщая память, нужны блокировки для общих данных; вычисления на Python не ускоряет из-за GIL
multiprocessingВычисления, нагружающие процессорОтдельные процессы и память; запуск и передача данных между процессами стоят дороже
asyncioМного одновременных сетевых операцийОдин поток, кооперативное переключение; нужны асинхронные библиотеки и нельзя блокировать цикл событий

На практике потоки и процессы чаще запускают не напрямую, а через пулы из модуля concurrent.futures: ThreadPoolExecutor и ProcessPoolExecutor.

Что такое корутина и что делает await?

Корутина — это функция, объявленная через async def; её вызов не выполняет код, а возвращает объект-корутину, который должен запустить цикл событий. await приостанавливает текущую корутину до готовности результата и на это время отдаёт управление циклу событий, чтобы тот выполнял другие задачи. Справка — в документации asyncio.

import asyncio
import time

async def fetch(name, delay):
    await asyncio.sleep(delay)      # имитация сетевого запроса
    return name

async def main():
    start = time.perf_counter()
    results = await asyncio.gather(fetch("users", 1), fetch("orders", 1))
    print(results, round(time.perf_counter() - start))

asyncio.run(main())    # ['users', 'orders'] 1

Два «запроса» по секунде выполнились за одну секунду, а не за две. Если заменить await asyncio.sleep(delay) на блокирующий time.sleep(delay), тот же код отработает за две секунды: блокирующий вызов останавливает весь цикл событий. Блокирующие функции выносят в поток через asyncio.to_thread(). Углублённо тема разобрана в курсе асинхронный Python.

SQL, ORM, веб и тестирование

Python-разработчик почти всегда работает с базой данных и HTTP, поэтому этот блок спрашивают даже на позиции без опыта. Если SQL нужно повторить с начала, поможет статья SQL для начинающих.

Чем INNER JOIN отличается от LEFT JOIN?

INNER JOIN возвращает только строки, для которых нашлось соответствие в обеих таблицах. LEFT JOIN возвращает все строки левой таблицы, а там, где соответствия справа нет, подставляет NULL. Пример запущен через встроенный модуль sqlite3.

import sqlite3

con = sqlite3.connect(":memory:")
con.executescript("""
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, total INTEGER);
INSERT INTO users VALUES (1, 'Anna'), (2, 'Oleh');
INSERT INTO orders VALUES (1, 1, 300), (2, 1, 200);
""")

inner = "SELECT u.name, o.total FROM users u INNER JOIN orders o ON o.user_id = u.id ORDER BY o.id"
left = "SELECT u.name, o.total FROM users u LEFT JOIN orders o ON o.user_id = u.id ORDER BY u.id, o.id"

print(con.execute(inner).fetchall())   # [('Anna', 300), ('Anna', 200)]
print(con.execute(left).fetchall())    # [('Anna', 300), ('Anna', 200), ('Oleh', None)]

Частое продолжение вопроса — «посчитайте заказы и сумму по каждому пользователю, включая тех, у кого заказов нет»:

SELECT u.name, COUNT(o.id) AS orders, COALESCE(SUM(o.total), 0) AS total
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.name
ORDER BY total DESC;
-- Anna | 2 | 500
-- Oleh | 0 | 0

Что такое SQL-инъекция и как от неё защититься?

SQL-инъекция — это уязвимость, при которой данные пользователя подставляются в текст запроса и выполняются как часть SQL. Защита — параметризованные запросы: значения передаются отдельно от текста запроса, и драйвер базы данных сам их экранирует. Никогда не собирайте запрос через f-строку или конкатенацию; об этом предупреждает и документация sqlite3.

name = "x' OR '1'='1"

unsafe = f"SELECT * FROM users WHERE name = '{name}'"
print(len(con.execute(unsafe).fetchall()))            # 2 — вернулись все пользователи

safe = "SELECT * FROM users WHERE name = ?"
print(len(con.execute(safe, (name,)).fetchall()))     # 0

Пример продолжает предыдущий: в таблице users две строки. ORM строит параметризованные запросы сам, но это не защищает код, в котором «сырой» SQL собран из строк.

Что такое ORM и что такое проблема N+1?

ORM (object-relational mapping) — это слой, который отображает таблицы на классы, а строки — на объекты, чтобы работать с базой данных через код на Python, а не через текст запросов. Примеры — Django ORM и SQLAlchemy. Проблема N+1 возникает, когда один запрос получает список объектов, а затем для каждого объекта выполняется ещё один запрос за связанными данными.

# N+1: один запрос за книгами и по одному запросу на автора каждой книги
for book in Book.objects.all():
    print(book.title, book.author.name)

# один запрос с JOIN
for book in Book.objects.select_related("author"):
    print(book.title, book.author.name)

На тестовых данных из трёх книг первый цикл выполнил 4 запроса, второй — 1. Здесь Book — модель Django с внешним ключом author. Для связей «один ко многим» и «многие ко многим» в Django служит prefetch_related; оба метода описаны в справочнике QuerySet.

Чем отличаются методы HTTP и что такое REST?

GET читает ресурс, POST создаёт новый, PUT заменяет целиком, PATCH изменяет частично, DELETE удаляет. REST — это стиль построения API, в котором адрес обозначает ресурс (/users/42), действие задаётся методом HTTP, сервер не хранит состояние клиента между запросами, а результат сообщается кодом ответа.

  • Идемпотентность. Повтор GET, PUT и DELETE даёт то же состояние сервера, что и один запрос; POST и PATCH идемпотентными не считаются. Определения — в RFC 9110.
  • Коды ответа. 2xx — успех (200 OK, 201 Created, 204 No Content), 3xx — перенаправление, 4xx — ошибка клиента (400, 401, 403, 404, 422), 5xx — ошибка сервера (500).
  • 401 и 403. 401 — сервер не знает, кто вы (нет или неверные учётные данные); 403 — знает, но прав на действие у вас нет.

Чем Django отличается от FastAPI?

Django — фреймворк «всё включено»: ORM, миграции, админ-панель, авторизация и шаблоны идут в комплекте. FastAPI — лёгкий фреймворк для API: он опирается на аннотации типов, проверяет по ним входные данные, сам строит документацию OpenAPI и изначально рассчитан на асинхронный код; ORM и остальное подключают отдельно.

КритерийDjangoFastAPI
НазначениеВеб-приложения целиком: страницы, формы, админка, APIAPI и сервисы
Работа с базой данныхВстроенная ORM и миграцииНе входит; обычно подключают SQLAlchemy
Проверка данныхФормы и сериализаторыМодели Pydantic по аннотациям типов
АсинхронностьПоддерживается, но исторически фреймворк синхронныйОсновной режим работы
from fastapi import FastAPI

app = FastAPI()

@app.get("/items/{item_id}")
def read_item(item_id: int):
    return {"item_id": item_id}

Этот минимальный пример повторяет учебник FastAPI: аннотация item_id: int заставляет фреймворк преобразовать и проверить параметр пути. Что хотят услышать: не «что лучше», а под какие задачи подходит каждый и на чём вы писали сами.

Что такое фикстуры и параметризация в pytest?

Фикстура — это функция с декоратором @pytest.fixture, которая готовит данные или окружение для теста; тест получает её результат, указав имя фикстуры в параметрах. Параметризация запускает один и тот же тест на нескольких наборах данных. Обе возможности описаны в документации pytest: fixtures и parametrize.

import pytest


def apply_discount(price, percent):
    if not 0 <= percent <= 100:
        raise ValueError("percent must be between 0 and 100")
    return price * (100 - percent) / 100


@pytest.fixture
def cart():
    return {"apple": 2, "pear": 1}


def test_cart_counts_items(cart):
    assert sum(cart.values()) == 3


@pytest.mark.parametrize(
    "price, percent, expected",
    [(100, 0, 100), (100, 25, 75), (80, 50, 40)],
)
def test_apply_discount(price, percent, expected):
    assert apply_discount(price, percent) == expected


def test_invalid_percent():
    with pytest.raises(ValueError):
        apply_discount(100, 150)

Команда python -m pytest находит файлы test_*.py и сообщает 5 passed: параметризованный тест считается за три. Что такое модульное тестирование и чем оно отличается от интеграционного, объяснено в глоссарии.

Практические задачи с решениями

На live coding чаще дают не алгоритмические головоломки, а небольшие задачи на словари, строки и списки — проверяют, знаете ли вы стандартную библиотеку и думаете ли о крайних случаях. Ниже четыре задачи такого типа; решения запущены, вывод в комментариях реальный.

Задача 1. Найти самые частые слова в тексте

Нужно вернуть n самых частых слов без учёта регистра и знаков препинания. Ожидаемое решение — collections.Counter; вручную то же делается словарём со счётчиками.

import re
from collections import Counter

def top_words(text, n=3):
    words = re.findall(r"[a-z']+", text.lower())
    return Counter(words).most_common(n)

print(top_words("The cat and the dog. The end, and THE start!", 2))
# [('the', 4), ('and', 2)]

Задача 2. Найти два числа с заданной суммой

Дан список чисел и целевая сумма; вернуть индексы двух элементов, которые её дают. Решение «в лоб» — два вложенных цикла, O(n²). Ожидаемое решение — один проход со словарём «число → индекс», O(n).

def two_sum(numbers, target):
    seen = {}
    for index, number in enumerate(numbers):
        if target - number in seen:
            return seen[target - number], index
        seen[number] = index
    return None

print(two_sum([2, 7, 11, 15], 9))   # (0, 1)
print(two_sum([3, 3], 6))           # (0, 1)
print(two_sum([1, 2], 10))          # None

Задача 3. Проверить баланс скобок

Строка содержит скобки трёх видов; определить, правильно ли они расставлены. Классическая задача на стек: открывающую скобку кладём, закрывающую сверяем с вершиной.

def is_balanced(text):
    pairs = {")": "(", "]": "[", "}": "{"}
    stack = []
    for char in text:
        if char in pairs.values():
            stack.append(char)
        elif char in pairs:
            if not stack or stack.pop() != pairs[char]:
                return False
    return not stack

print(is_balanced("{[()]}"), is_balanced("([)]"), is_balanced("(("), is_balanced(""))
# True False False True

Задача 4. Развернуть вложенный список

Из списка произвольной вложенности получить плоскую последовательность. Задача проверяет рекурсию и генераторы: yield from передаёт наружу все значения вложенного генератора.

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

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

В каждой задаче после решения назовите сложность и крайние случаи: пустой ввод, один элемент, повторяющиеся значения. Потренироваться на основах можно в бесплатном курсе по Python.

Типичные ошибки кандидатов

Чаще всего кандидатов подводит не незнание отдельного факта, а манера отвечать: заученные определения без примера и молчание во время решения задачи. Список — из моего опыта подготовки студентов.

  • Определение без примера. «Декоратор — это функция, которая принимает функцию» звучит верно, но интервьюер ждёт, что вы тут же напишете простейший декоратор.
  • Молчаливое решение задачи. Оценивают ход мысли. Проговаривайте: что уточняете, какой план, почему выбрали словарь, а не список.
  • Код без крайних случаев. Пустой список, None, отрицательные числа, повторяющиеся значения — назовите их раньше, чем о них спросят.
  • Угадывание вместо «не знаю». Уверенная выдумка вскрывается следующим вопросом и подрывает доверие к остальным ответам.
  • Чужой проект в портфолио. Код, сгенерированный ассистентом или переписанный с урока, который вы не можете объяснить построчно.
  • Пробел в SQL. Сильный Python не спасает, если кандидат не может написать JOIN с GROUP BY.
  • Путаница в базовых терминах. Аргумент и параметр, итератор и итерируемый объект, поток и процесс, 401 и 403.
  • Спор с интервьюером вместо проверки. Если не согласны — предложите запустить код и посмотреть.
  • Отсутствие вопросов к компании. Это читается как отсутствие интереса к работе.

Какие вопросы задать интервьюеру?

Спрашивайте о том, как будет выглядеть ваша работа в первые месяцы: задачи, наставник, процесс ревью и критерии оценки. Эти ответы помогают сравнить предложения и показывают, что вы выбираете осознанно.

  • Какими задачами я буду заниматься в первые один-три месяца?
  • Будет ли у меня наставник и как устроено введение в проект?
  • Как проходит код-ревью и кто ревьюит код начинающих разработчиков?
  • Какие версии Python и какие фреймворки используются в проекте? Есть ли тесты и как они запускаются?
  • Как устроен рабочий процесс: спринты, планирование, релизы?
  • По каким критериям оценивают результат испытательного срока?
  • Как в команде относятся к использованию AI-ассистентов в работе?
  • Как выглядит рост от Junior до Middle в вашей компании и что для этого нужно показать?
  • Какие следующие шаги после этого собеседования и когда ждать ответа?

План подготовки на две недели

Двух недель по одному-два часа в день хватает, чтобы повторить темы и натренировать ответы вслух, — при условии, что сам язык вы уже изучали. Это ориентир преподавателя, а не норматив: подстраивайте план под свои слабые места.

ДниТемаЧто сделать
1–2Типы данных и изменяемостьЗапустить все примеры блока, изменить их и предсказать вывод до запуска
3–4Функции и замыканияНаписать по памяти функцию с *args и **kwargs, счётчик на замыкании, объяснить ловушку со списком по умолчанию
5–6ООПСпроектировать три-четыре связанных класса, добавить __repr__, __eq__, dataclass; нарисовать MRO для ромбовидного наследования
7Итераторы, генераторы, декораторыНаписать генератор для чтения файла построчно и два декоратора: с параметрами и без
8Исключения, модули, типыДобавить в свой проект собственное исключение, аннотации типов и проверить структуру requirements.txt
9GIL и asyncioСвоими словами объяснить, когда нужны потоки, процессы и asyncio; запустить пример с gather
10–11SQL, ORM, HTTPДесять запросов с JOIN и GROUP BY на своей базе; повторить методы и коды ответа; найти N+1 в своём проекте
12ТестированиеПокрыть тестами pytest один модуль проекта: фикстура, параметризация, проверка исключения
13Практические задачиРешить четыре задачи из этой статьи и ещё пять похожих с таймером и вслух
14Проект и пробное интервьюРассказать о проекте за пять минут, пройти пробное собеседование с другом или записать себя на видео

Если вы ещё выбираете первый язык, а не готовитесь к конкретному собеседованию, начните со сравнения Python или JavaScript: что учить первым.

Частые вопросы

Сколько времени нужно на подготовку к собеседованию на Junior Python-разработчика?

Если основы языка, SQL и свой проект уже есть, на повторение и тренировку ответов вслух обычно хватает двух недель по одному-два часа в день. Если тем из этой статьи вы ещё не проходили, это уже не подготовка к собеседованию, а обучение, и оно занимает месяцы. Это оценка автора как преподавателя, а не статистика.

Будет ли на собеседовании live coding?

Скорее всего да, в том или ином виде: небольшая задача в общем редакторе, разбор готового фрагмента кода или вопрос «что выведет эта программа». Формат зависит от компании, поэтому его стоит уточнить у рекрутера заранее. Готовьтесь решать задачи вслух: сначала уточнить условие, потом назвать идею и только после этого писать код.

Нужно ли знать алгоритмы и структуры данных на собеседовании Junior?

На базовом уровне да: чем список отличается от словаря и множества, сколько стоит поиск в каждом из них, что такое стек и очередь, как оценить сложность цикла и вложенного цикла. Сложные алгоритмы на графах и динамическое программирование на позицию Junior спрашивают заметно реже, но это зависит от компании.

Можно ли пользоваться AI-ассистентом на собеседовании?

Только если интервьюер прямо это разрешил. Одни компании просят решать задачу без подсказок, другие, наоборот, смотрят, как кандидат работает с ассистентом и проверяет его ответ. Если правила не названы, спросите в начале. Пользоваться подсказками тайно нельзя: это заметно по ответам и заканчивает собеседование.

Что делать, если не знаю ответа на вопрос?

Сказать об этом прямо и показать ход рассуждения: что вы знаете рядом с этой темой, как бы проверили гипотезу, где посмотрели бы в документации. Честное «с этим не работал, но предположу вот что и вот почему» воспринимается лучше, чем уверенная выдумка, которую интервьюер разберёт следующим же вопросом.

Нужен ли английский на собеседовании Python-разработчика?

Английский для чтения нужен в любом случае: документация, сообщения об ошибках и названия в коде написаны на английском. Разговорную часть проверяют там, где команда или заказчик работают на английском, обычно это несколько минут разговора о себе и своём проекте. Требуемый уровень указан в вакансии, ориентируйтесь на неё.

Стоит ли выполнять тестовое задание?

Да, если объём разумный и вакансия вам интересна: для кандидата без опыта это возможность показать код вместо строчек в резюме. Перед началом уточните срок и критерии оценки. Сдавайте с README, тестами и аккуратной историей коммитов и будьте готовы объяснить каждое решение на следующем этапе.

Что спрашивают про собственные проекты?

Что делает проект, как он устроен, почему выбраны именно эти библиотеки и структура базы данных, что было самым сложным и что вы переделали бы сейчас. Нередко просят открыть репозиторий и объяснить конкретный фрагмент. Поэтому в портфолио должен быть только код, который вы понимаете до последней строки.

Чем собеседование на Middle отличается от собеседования на Junior?

От Junior ждут уверенного знания языка и умения решить небольшую задачу. На Middle к этому добавляются вопросы о том, почему решение устроено именно так: конкурентность и GIL, производительность запросов к базе данных, проектирование API, тестируемость кода, опыт разбора ошибок в работающем сервисе.

Источники

Программа курса Python →

Фото автора: Александр Цимбалюк

Об авторе

Тренер Prog Academy по Java и Python

Преподаёт программирование более 12 лет, автор офлайн- и онлайн-курсов Prog Academy по Java и Python. В блоге пишет о Java, Python и анализе данных на Python.

Контакт

Записаться на консультацию

Укажите актуальный номер, мы позвоним в любую страну :)