Artikel Testning · Prestanda

En rad i loopen: N+1-frågorna som AI-koden skriver in och testerna inte ser

Modellen skriver läsbar, idiomatisk kod som loopar över en samling och hämtar en relation per varv. Med fem rader i testdatan är svaret rätt och testet grönt på millisekunder. Med några tusen rader i produktion är svaret fortfarande rätt — men bakom det ligger några tusen databasfrågor.

Senast granskad: 23 aug 2026

Mörk bild där en genomskinlig approfil av nätverkspunkter till vänster sänder ut ett tätt knippe av hundratals fina vita trådar som löper åt höger och slutar i en lysande syragrön kant mot en stor borstad mörk cylinder.
Ett anrop in, hundratals frågor ut — och ingen mätare som räknar dem förrän du sätter dit en.

Kortfattat

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.

Läsning, inte skrivning

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:

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.

Fallgropen med den hemmabyggda räknaren

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].

En join är inte gratis. Vid en en-till-många-relation upprepas förälderns kolumner en gång per barnrad. Tio ordrar med tjugo rader vardera ger 200 resultatrader där orderns fält står 20 gånger var. Det är färre frågor och mer data på tråden.
Två frågor är ofta rätt svar. Mönstret ”hämta föräldrarna, hämta barnen med IN, para ihop i minnet” ger konstant antal frågor utan radupprepning. Det är därför både Django och SQLAlchemy har en variant som gör just det.
Målet är konstant, inte minimalt. Ett test som kräver exakt 1 fråga tvingar fram en join där två frågor hade varit billigare. Hävda att talet inte växer med N — det är egenskapen du bryr dig om.
Kontrollen hör hemma före merge. En frågeräknare som bara körs lokalt hittar inget. Lägg den där testsviten redan körs, som en av stoppreglerna före merge.

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.


Källor

[1] Django, Testing toolsassertNumQueries, 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 ConfigurationEngine som ”home base” med anslutningspool, och vad echo loggar. [4] SQLAlchemy 2.0, Core EventsConnectionEvents.before_cursor_execute, dess parametrar och executemany-flaggan. [5] Django, Database instrumentationconnection.execute_wrapper() och omslagets signatur. [6] Django, FAQ: Databases and modelsconnection.queries är bara tillgänglig när DEBUG är True. [7] Django, QuerySet API referenceselect_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.