SQLAlchemys dokumentation definierar felet exakt: ”the N plus one problem, which states that for any N objects loaded, accessing their lazy-loaded attributes means there will be N+1 SELECT statements emitted” [2]. Felet handlar alltså inte om att svaret blir fel, utan om hur många frågor som ligger bakom ett rätt svar. Ingen vanlig kontroll fäller det, eftersom antalet frågor inte är något testsviten normalt tittar på. Motmedlet är att göra antalet till ett värde du hävdar: en frågeräknare kring ett endpointtest, och testdata i två storlekar så att räknaren kan säga något alls.
Fem rader i testdatan gömmer tusen frågor
Be en modell om ”en endpoint som listar skickade ordrar med kundnamn” och du får ofta något i den här stilen. Den är kort, den är läsbar och den gör rätt:
def orderlista(request):
ordrar = Order.objects.filter(status="skickad")
return JsonResponse({
"ordrar": [
{
"id": order.id,
"kund": order.kund.namn,
"summa": order.summa,
}
for order in ordrar
]
})
Raden order.kund.namn är den dyra. Den ser ut som en attributläsning men är en databasfråga, en gång per order. Med 200 skickade ordrar blir det 1 fråga för listan plus 200 för kunderna: 201 frågor. Antag att varje fråga kostar 2 millisekunder i tur och retur mot databasen — då har du lagt drygt 0,4 sekunder enbart på väntan, innan någon rad har serialiserats. Siffran 2 ms är ett räkneexempel, inte en uppmätt egenskap hos din databas; poängen är att kostnaden växer linjärt med antalet rader medan koden ser konstant ut.
Testet som följer med koden skapar typiskt tre till fem ordrar. Då är samma endpoint 4–6 frågor och några millisekunder. Grönt. Testet mäter att svaret är rätt, och svaret är rätt. Det som saknas är ett påstående om kostnaden.
Tre former som ser olika ut i en diff
Mönstret är lätt att känna igen i exemplet ovan eftersom loopen och relationen står på samma rad. I verklig AI-genererad kod är det oftare något av de tre följande, och bara det första har en synlig loop.
1. Loopen med lazy-laddad relation. Formen från exemplet. Kännetecken: en iteration över en queryset eller lista, och inuti den ett attribut som korsar en relationsgräns. Det här är den form som en granskare faktiskt hittar när hen letar.
2. Serialiseraren som hämtar per objekt. Här finns ingen loop i diffen — loopen ligger i ramverket, och den dyra raden ligger i en metod som anropas en gång per objekt:
class OrderSerializer(serializers.ModelSerializer):
kundnamn = serializers.SerializerMethodField()
def get_kundnamn(self, order):
return order.kund.namn
Metoden ser ut att kosta noll. Den läses en gång i granskningen och körs en gång per rad i svaret. Samma sak händer med en to_dict() som anropas i en listkomprehension två filer bort.
3. Frågan bakom en hjälpmetod eller en property. Den svåraste, för här är anropsstället oskyldigt:
class Order(models.Model):
...
@property
def antal_rader(self):
return self.rader.count()
På anropsstället står bara order.antal_rader. Ingenting i den raden antyder ett nätverksanrop. Att en property kan innehålla en fråga är precis den sorts implicita kostnad som är svår att se i en kodgranskning av AI-genererad kod, eftersom granskningen läser anropsstället och definitionen var för sig.
De tre formerna rör hur befintlig data läses, inte hur schemat ser ut. Om samma diff också ändrar tabeller eller index är det ett annat problem med andra risker — det hör hemma i databasmigreringar med AI. En N+1 kan mycket väl leva vidare oförändrad genom en migrering: frågorna blir varken fler eller färre av att kolumnen får ett index, bara snabbare var för sig.
Lägg en frågeräknare kring testet du redan har
Django dokumenterar en färdig kontroll: assertNumQueries(num, func, *args, **kwargs) ”asserts that when func is called with *args and **kwargs that num database queries are executed”, och den kan användas som kontexthanterare [1]. Det betyder att du inte behöver ett nytt test — du behöver en rad runt det du redan har:
from django.test import TestCase
class OrderlistaTest(TestCase):
def test_orderlistan_svarar_med_ratt_kunder(self):
skapa_ordrar(3)
with self.assertNumQueries(2):
svar = self.client.get("/api/ordrar/")
self.assertEqual(len(svar.json()["ordrar"]), 3)
Avvägningen ligger i talet 2. Kontrollen är exakt, inte en övre gräns, och den räknar alla frågor i blocket — även sådana som inte har med din loop att göra: en sessionsuppslagning, en behörighetskontroll, en hämtning av den inloggade användaren. Ett exakt tal fäller därför inte bara N+1 utan också en helt ofarlig extra fråga någon lägger till nästa månad. Det är delvis en fördel: talet blir en budget som någon måste ta ställning till för att ändra. Men det kräver att du skriver ut varför talet är vad det är, annars justerar nästa utvecklare bara siffran tills testet blir grönt igen.
Testdatan avgör om räknaren säger något
Det här är steget som avgör om räknaren är ett verkligt skydd eller bara dekoration. Titta på vad talet blir för olika mängder testdata i det trasiga exemplet:
- 1 order: 1 fråga för listan + 1 för kunden = 2 frågor. Den rättade versionen ger också 2 frågor.
- 3 ordrar: 4 frågor mot 2. Nu skiljer det.
- 12 ordrar: 13 frågor mot 2.
Med en enda rad i testdatan är assertNumQueries(2) alltså grönt för både den trasiga och den rättade koden. Testet finns, det passerar, och det bevakar ingenting. Det är samma sorts blindhet som gjorde att det ursprungliga testet var grönt — bara flyttad ett steg.
Formulera därför påståendet som det faktiskt lyder: antalet frågor ska vara oberoende av antalet rader. Kör samma endpoint mot två olika datamängder och hävda samma tal:
class OrderlistaTest(TestCase):
def test_antalet_fragor_ar_oberoende_av_antalet_ordrar(self):
# 2 = en fråga för ordrarna, en för kunderna via prefetch.
for antal in (3, 12):
with self.subTest(antal=antal):
Order.objects.all().delete()
skapa_ordrar(antal)
with self.assertNumQueries(2):
svar = self.client.get("/api/ordrar/")
self.assertEqual(len(svar.json()["ordrar"]), antal)
Nu fäller testet mönstret oavsett vilket av de tre formerna som återinförs, och det gör det utan att du behöver veta var i koden frågan gömde sig. Skillnaden mot property-baserad testning av AI-genererad kod är värd att notera: där genereras indata för att hitta ett svar som är fel, här varieras datamängden för att hitta en kostnad som växer bakom ett svar som är rätt. Tekniskt är det två olika verktyg för två olika felklasser, och de utesluter inte varandra.
Räknaren utanför Django: en lyssnare på motorn
Utan Djangos testklass får du bygga räknaren själv. I SQLAlchemy är Engine enligt dokumentationen ”home base” för databasen och dess DBAPI, levererad genom en anslutningspool [3] — och det är där händelserna finns. Eventet before_cursor_execute(conn, cursor, statement, parameters, context, executemany) avlyssnar körningen och tar emot SQL-satsen innan den går mot en cursor [4]:
import contextlib
from sqlalchemy import event
@contextlib.contextmanager
def raknade_fragor(engine):
antal = 0
def rakna(conn, cursor, statement, parameters, context, executemany):
nonlocal antal
antal += 1
event.listen(engine, "before_cursor_execute", rakna)
try:
yield lambda: antal
finally:
event.remove(engine, "before_cursor_execute", rakna)
def test_orderlistan_har_fast_kostnad(klient, engine):
for storlek in (3, 12):
skapa_ordrar(storlek)
with raknade_fragor(engine) as antal:
klient.get("/api/ordrar")
assert antal() == 2
Två fallgropar här. Den ena: eventet bär flaggan executemany, så en batchad insättning av hundra rader räknas som en körning [4]. Räknaren mäter körningar mot en cursor, inte rader — vilket är precis rätt mätvärde för N+1, men fel mätvärde om du tror att du mäter databasbelastning i stort. Den andra: echo=True på motorn loggar alla satser [3] och är utmärkt för att se problemet under felsökning, men en logg fäller ingenting. Ett CI-jobb läser inte stdout och blir arg.
I Django finns motsvarande krok utanför testsviten. connection.execute_wrapper() ger en kontexthanterare som installerar ett omslag runt frågekörningar, med signaturen wrapper(execute, sql, params, many, context), och omslaget läggs på det trådlokala anslutningsobjektet [5]. Det är vägen om du vill räkna frågor i en management-kommandokörning eller kring ett bakgrundsjobb.
Frestelsen är att räkna len(connection.queries) i stället. Djangos egen dokumentation är tydlig med begränsningen: ”connection.queries is only available if DEBUG is True” [6]. En räknare byggd på den listan returnerar noll i varje miljö där DEBUG är avstängt — alltså i produktion, och i varje testkörning som medvetet speglar produktionsinställningarna. Den misslyckas inte högljutt. Den rapporterar bara att allt är i sin ordning.
Rätta mönstret — och betala med rätt sorts fråga
När räknaren väl fäller något finns det mer än en väg ut, och de kostar olika. Django beskriver skillnaden rakt: select_related ”works by creating an SQL join and including the fields of the related object in the SELECT statement” och är därför begränsad till envärda relationer — främmande nyckel och en-till-en — medan prefetch_related ”does a separate lookup for each relationship, and does the 'joining' in Python” och därmed även klarar många-till-många och många-till-en [7].
SQLAlchemy har samma tudelning under andra namn. joinedload kopplar en JOIN — som standard en LEFT OUTER JOIN — till den SELECT som ändå körs och fyller relationen ur samma resultatmängd, medan selectinload kör en andra SELECT som refererar till föräldrarnas primärnycklar i en IN-sats [2].
Var räknaren ska sitta är en redaktionell fråga snarare än en teknisk. Att sätta ett fråganta på varje endpoint i sajten ger en testsvit som går sönder av varje ofarlig ändring. Välj de endpoints som returnerar listor, de som anropas av en förstasida, och de som redan har varit långsamma en gång. Resten av teststrategin — vad som testas, hur mycket och varför — ligger i guiden till testning av AI-genererad kod; det här är en enskild felklass som har fått ett eget mätvärde.
[1] Django, Testing tools — assertNumQueries, dess signatur och användning som kontexthanterare.
[2] SQLAlchemy 2.0, Relationship Loading Techniques — definitionen av N+1 samt vad joinedload och selectinload genererar.
[3] SQLAlchemy 2.0, Engine Configuration — Engine som ”home base” med anslutningspool, och vad echo loggar.
[4] SQLAlchemy 2.0, Core Events — ConnectionEvents.before_cursor_execute, dess parametrar och executemany-flaggan.
[5] Django, Database instrumentation — connection.execute_wrapper() och omslagets signatur.
[6] Django, FAQ: Databases and models — connection.queries är bara tillgänglig när DEBUG är True.
[7] Django, QuerySet API reference — select_related mot prefetch_related.
Samtliga källor lästa 23 augusti 2026. Kodexemplen är förkortade illustrationer utan felhantering och indatavalidering, och de innehåller avsiktligt det mönster artikeln beskriver. Tidsuppgiften 2 ms per fråga är ett räkneexempel, inte en mätning.