# Praktyczny przewodnik po schemacie eksportu GA4 do BigQuery

**Author:** Piotr Litwa - GTM & Analytics Specialist  
**Published:** 2026-04-10  
**URL:** https://piotrlitwa.com/articles/pl/ga4-bigquery-schemat-eksportu.html  
**Language:** pl  
**Keywords:** ga4 bigquery export schema, ga4 bigquery schema, event_params unnest, ga4 export table structure, ga4 bigquery nested fields

---

Gdy pierwszy raz otwierasz eksport GA4 w BigQuery i odpalasz `SELECT * FROM events_*`, wygląda zwodniczo prosto, dopóki nie spróbujesz odczytać jednego adresu URL strony i nie zorientujesz się, że jest zakopany trzy poziomy głęboko w czymś, co nazywa się `event_params`. To moment, w którym większość ludzi się zacina, i to nie Twój SQL jest problemem. Rzecz w tym, że schemat GA4 ma inny kształt niż wszystko, co dotąd odpytywałeś.

Oto model myślowy, który to naprawia. Eksport GA4 do BigQuery to jeden wiersz na zdarzenie, trzymany w dziennych tabelach, z ciekawymi szczegółami schowanymi w zagnieżdżonych, powtarzalnych polach, które musisz `UNNEST`, żeby je odczytać. Gdy to zaskoczy, cały schemat przestaje przerażać, a zaczyna być najpotężniejszą wersją Twoich danych analitycznych, jakiej kiedykolwiek dotkniesz.

Ten przewodnik przechodzi przez strukturę: tabele, kolumny najwyższego poziomu, zagnieżdżone pola, na których wszyscy się potykają, i jak faktycznie wyciągnąć wartość. Jeśli jeszcze nie włączyłeś eksportu, [przewodnik konfiguracji GA4 z BigQuery](https://piotrlitwa.com/articles/pl/ga4-bigquery-konfiguracja.html) to obejmuje; ten zakłada, że dane już wpadają i potrzebujesz zrozumieć ich kształt.

<div class="key-takeaway">
<strong>Najważniejsze:</strong> eksport GA4 trzyma jeden wiersz na zdarzenie w dzielonych dziennie tabelach `events_YYYYMMDD`. Kolumny, na których zależy Ci najbardziej, adresy URL, identyfikatory sesji, parametry niestandardowe, żyją wewnątrz powtarzalnych pól `RECORD` jak `event_params`, a czytasz je podzapytaniem `UNNEST`. Opanuj ten jeden wzorzec, a reszta schematu jest prosta.
</div>

## Kształt eksportu

Gdy łączysz GA4 z BigQuery, dostajesz zbiór danych o nazwie `analytics_<property_id>`, a w nim zestaw tabel według stałego wzorca nazewnictwa.

- **`events_YYYYMMDD`**: jedna tabela na dzień, eksport dzienny. Tu trafia prawie każde Twoje zapytanie.
- **`events_intraday_YYYYMMDD`**: tabela tego samego dnia, strumieniowa, jeśli włączyłeś eksport streamingowy. Jest ciągle aktualizowana i zostaje zastąpiona finalną tabelą dzienną, gdy dzień się zamknie.
- **`pseudonymous_users_*`** i **`users_*`**: tabele na poziomie użytkownika, dostępne przy nowszych opcjach eksportu, trzymające najnowszy stan na użytkownika, a nie na zdarzenie.

Tabele dzienne są *dzielone* (sharded), a nie partycjonowane, co ma znaczenie dla tego, jak odpytujesz po datach. Żeby przeskanować zakres dat, używasz tabeli z symbolem wieloznacznym i filtrujesz po sufiksie:

```
SELECT event_name, event_date
FROM `your-project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260101' AND '20260131'
```

`_TABLE_SUFFIX` to część `YYYYMMDD` każdej nazwy tabeli. Filtrowanie po nim trzyma BigQuery z dala od skanowania całej Twojej historii (i całego budżetu) przy każdym zapytaniu.

## Jeden wiersz to jedno zdarzenie

To serce schematu. Każda interakcja, każde `page_view`, `scroll`, `purchase`, zdarzenie niestandardowe, to pojedynczy wiersz. Nie ma "tabeli sesji" ani "tabeli odsłon", jak miało Universal Analytics. Sesję rekonstruujesz ze zdarzeń, które dzielą ten sam `ga_session_id`.

Kolumny najwyższego poziomu opisują samo zdarzenie. To te, które odczytasz wprost, bez rozpakowywania:

| Kolumna | Typ | Co to jest |
|---|---|---|
| `event_date` | STRING | Data, jako `YYYYMMDD` |
| `event_timestamp` | INTEGER | Kiedy się odpaliło, w **mikrosekundach** od epoki |
| `event_name` | STRING | `page_view`, `purchase`, Twoja nazwa niestandardowa |
| `user_pseudo_id` | STRING | Identyfikator urządzenia/klienta |
| `user_id` | STRING | Twój własny ID zalogowanego, jeśli go ustawisz |
| `event_params` | RECORD (powtarzalne) | Parametry na zdarzenie (zagnieżdżone) |
| `user_properties` | RECORD (powtarzalne) | Właściwości na użytkownika (zagnieżdżone) |
| `items` | RECORD (powtarzalne) | Pozycje e-commerce (zagnieżdżone) |
| `device`, `geo`, `traffic_source` | RECORD | Ustrukturyzowany kontekst (zagnieżdżony, niepowtarzalny) |

Zauważ, że wszystko naprawdę przydatne, parametry, właściwości i pozycje, to `RECORD`. Tak BigQuery nazywa strukturę zagnieżdżoną, a te powtarzalne to tablice. To część, przy której warto zwolnić.

## Zagnieżdżone pola, na których wszyscy się potykają

`event_params` to to, z czym powalczysz najpierw. To powtarzalny rekord, tablica, w której każdy wpis to para klucz/wartość. Więc pojedynczy wiersz `page_view` niesie tablicę `event_params`, która może trzymać `page_location`, `page_title`, `ga_session_id`, `engagement_time_msec` i dowolne parametry niestandardowe, które wysłałeś.

Haczyk, który łapie każdego: `value` nie jest zwykłym polem. To samo w sobie rekord z czterema typowanymi podpolami, a tylko jedno z nich jest wypełnione na parametr:

- `value.string_value`
- `value.int_value`
- `value.float_value`
- `value.double_value`

`page_location` żyje w `string_value`. `ga_session_id` żyje w `int_value`. Wybierz złe, a dostajesz `NULL`, bez błędu, który by Ci powiedział dlaczego. `user_properties` działa dokładnie tak samo, klucz plus typowana wartość, a `items` to tablica rekordów z własnymi polami jak `item_id`, `item_name`, `price` i `quantity`.

## Jak faktycznie odczytać parametr

Do `event_params` nie sięgasz notacją z kropką. `UNNEST` w podzapytaniu i filtrujesz po kluczu, którego chcesz. To pojedynczo najważniejszy wzorzec w całym schemacie:

```
SELECT
  event_date,
  event_name,
  (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'page_location') AS page_location,
  (SELECT value.int_value    FROM UNNEST(event_params) WHERE key = 'ga_session_id') AS session_id
FROM `your-project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX = '20260410'
  AND event_name = 'page_view'
```

Każde `(SELECT ... FROM UNNEST(event_params) WHERE key = '...')` wyciąga jeden parametr do własnej kolumny, dobierając właściwe typowane podpole. Gdy napiszesz to dwa razy, wchodzi w pamięć mięśniową. Zbuduj bazowe zapytanie, które spłaszcza pięć czy sześć parametrów, których używasz bez przerwy, i zapisz je; wykorzystasz je w każdym projekcie.

Kiedy Daniel, analityk nowy w eksporcie, próbował zsumować niestandardowy parametr `value` zwykłym `SUM(event_params.value)`, BigQuery rzucił błędy typu, a gdy wymusił, dostał nulle. Dane były w porządku. Sumował strukturę, zamiast sięgnąć do `value.double_value` przez `UNNEST`. Dziesięć minut zamieszania, jeden wzorzec do nauczenia, nigdy więcej problemu.

## Gdzie faktycznie lądują Twoje wymiary niestandardowe

To pytanie, które prędzej czy później pojawia się w każdej konfiguracji GA4: zarejestrowałeś wymiary niestandardowe w interfejsie, więc gdzie są w eksporcie? Odpowiedź zależy w całości od *zakresu* (scope), który im nadałeś, a schemat odzwierciedla ten zakres dokładnie.

| Zakres wymiaru niestandardowego | Gdzie ląduje | Jak odczytujesz |
|---|---|---|
| Na zdarzenie (event-scoped) | `event_params` | `UNNEST(event_params)` po nazwie parametru |
| Na użytkownika (user-scoped) | `user_properties` | `UNNEST(user_properties)` po nazwie właściwości |
| Na pozycję (item-scoped) | `items` | `UNNEST(items)`, potem pole pozycji |

Więc niestandardowy parametr, który wysyłasz ze zdarzeniem, powiedzmy `content_group` albo `logged_in_status`, pojawia się jako klucz wewnątrz `event_params`, czytany tym samym wzorcem `UNNEST` co dowolny wbudowany parametr. Wymiar na użytkownika jak `membership_tier` ląduje za to w `user_properties`, bo opisuje osobę, a nie pojedyncze zdarzenie. A wymiary na pozycję jadą wewnątrz tablicy `items`, obok `item_id` i `price`.

Praktyczny wniosek: nie ma osobnej tabeli "wymiarów niestandardowych", w którą trzeba zajrzeć, i nic nie jest dla Ciebie przemianowane. Pod jaką nazwą wysłałeś parametr, taki dokładnie `key` filtrujesz w BigQuery. Jeśli nie możesz znaleźć wymiaru, prawie zawsze patrzysz w rekord złego zakresu, a nie brakuje danych. Sprawdź, jak został zarejestrowany w GA4, i rozpakuj pasujące pole.

## Pułapki warte poznania, zanim zaufasz liczbie

Schemat jest logiczny, gdy go znasz, ale garść szczegółów po cichu wykrzywi wyniki, jeśli je przeoczysz.

| Pułapka | Czemu gryzie | Co zrobić |
|---|---|---|
| Dzielone, nie partycjonowane | `WHERE event_date = ...` i tak skanuje każdą tabelę | Filtruj po `_TABLE_SUFFIX` z `events_*` |
| Mikrosekundowe znaczniki czasu | `event_timestamp` to mikro, nie milisekundy | Podziel przez 1 000 000 albo użyj `TIMESTAMP_MICROS()` |
| Typowane podpola `value` | Złe podpole zwraca `NULL` po cichu | Dopasuj typ: string vs int vs double |
| Intraday plus dzienna | Odpytanie obu liczy dziś podwójnie | Odpytuj tabele dzienne; intraday traktuj jako prowizoryczne |
| Dane przychodzące z opóźnieniem | Dzisiejsza tabela dopełnia się przez ~72 godziny | Do raportowania używaj zamkniętych dni |
| Brak unikalnego ID zdarzenia | Deduplikacja nie jest wbudowana | Zbuduj klucz z user_pseudo_id + timestamp + nazwa |

Ta z intraday plus dzienną łapie ludzi odpalających `events_*` bez zastanowienia. Jeśli istnieją i `events_20260410`, i `events_intraday_20260410`, skan z symbolem wieloznacznym czyta obie, a Twój bieżący dzień wygląda na zawyżony. Do czegokolwiek, na czym będziesz działać, odpytuj zamknięte tabele dzienne, a intraday zostaw do debugowania na żywo.

## Dwa wzorce, które pokrywają większość pracy

Poza spłaszczaniem parametrów dwa kształty zapytań robią większość ciężkiej roboty. Po pierwsze, **odtworzenie metryki sesji**: policz distinct `ga_session_id` na `user_pseudo_id`, żeby dostać sesje, skoro schemat sam Ci ich nie podaje. Po drugie, **e-commerce z `items`**: `CROSS JOIN UNNEST(items)`, żeby rozłożyć każdy zakup na pozycje, a potem agreguj przychód po `item_name` albo `item_category`. Te dwa, plus bazowe zapytanie spłaszczające parametry, to 80% tego, jak wygląda prawdziwa analiza na eksporcie.

Jeśli Twoje zapytania zaczną robić się drogie przy tej zabawie, to osobna umiejętność, a [przewodnik po optymalizacji kosztów BigQuery](https://piotrlitwa.com/articles/pl/bigquery-koszty-optymalizacja.html) obejmuje trzymanie rachunku w ryzach podczas eksploracji. A jeśli samo BigQuery jest dla Ciebie wciąż nowe, [wyjaśnienie BigQuery po ludzku](https://piotrlitwa.com/articles/pl/bigquery-co-to-jest-poradnik.html) to miejsce na start przed schematem.

## Kiedy naprawdę potrzebujesz surowego eksportu

Nie odpytujesz BigQuery do wszystkiego. Standardowe raporty GA4 są szybsze do codziennych pytań i nic nie kosztują. Surowy eksport zarabia na swoje miejsce, gdy trafiasz na ściany, które stawia interfejs: potrzebujesz danych niepróbkowanych, chcesz złączyć GA4 z CRM albo danymi o kosztach, modelujesz niestandardową atrybucję albo potrzebujesz szczegółu na poziomie zdarzeń, który raporty agregują. [Oficjalna dokumentacja schematu eksportu GA4](https://support.google.com/analytics/answer/7029846) jest warta zakładki dla pełnej listy pól.

Dla większości zespołów eksport staje się niezbędny w chwili, gdy pytanie zaczyna się od "czy możemy połączyć nasze dane GA4 z...". To też moment, w którym warto mieć kogoś, kto żył w tym schemacie, żeby ustawił Twoje bazowe zapytania i model danych. To część tego, czym zajmuje się [konsultant GA4](https://piotrlitwa.com/articles/pl/konsultant-ga4.html), i oszczędza tygodni nulli i podwójnych liczeń. Jeśli wciąż rozeznajesz się w samym GA4, [wyjaśnienie GA4](https://piotrlitwa.com/articles/pl/google-analytics-4-co-to-jest-jak-dziala.html) pokrywa model, na którym zbudowany jest eksport.

## FAQ

### Czym jest schemat eksportu GA4 do BigQuery?
To struktura, której GA4 używa, gdy strumieniuje Twoje surowe dane zdarzeń do BigQuery: zbiór danych o nazwie `analytics_<property_id>` zawierający dzienne tabele `events_YYYYMMDD`, gdzie każdy wiersz to pojedyncze zdarzenie, a szczegółowe parametry są trzymane w zagnieżdżonych, powtarzalnych polach `RECORD` jak `event_params`, `user_properties` i `items`.

### Po co mi UNNEST, żeby odpytać GA4 w BigQuery?
Bo parametry, których chcesz, jak `page_location` czy `ga_session_id`, żyją wewnątrz `event_params`, które jest powtarzalnym rekordem (tablicą), a nie zwykłą kolumną. `UNNEST` spłaszcza tę tablicę, żebyś mógł odfiltrować konkretny klucz i odczytać jego wartość, zwykle jako podzapytanie zwracające jeden parametr na kolumnę.

### Dlaczego event_params.value zwraca NULL?
Bo `value` to rekord z czterema typowanymi podpolami, `string_value`, `int_value`, `float_value` i `double_value`, a wypełnione jest tylko to pasujące do typu parametru. Jeśli czytasz `value.int_value` dla parametru zapisanego jako string, dostajesz `NULL` bez błędu. Dopasuj podpole do faktycznego typu parametru.

### Jaka jest różnica między tabelami events a events_intraday?
`events_YYYYMMDD` to sfinalizowana tabela dzienna, a `events_intraday_YYYYMMDD` to ciągle aktualizowana tabela tego samego dnia, która istnieje tylko, jeśli włączyłeś eksport streamingowy. Tabela intraday jest prowizoryczna i zostaje zastąpiona tabelą dzienną, gdy dzień się zamknie, więc odpytanie obu naraz liczy bieżący dzień podwójnie.

### Tabele GA4 w BigQuery są partycjonowane czy dzielone?
Są dzielone (sharded), czyli jedna fizyczna tabela na dzień z sufiksem daty, a nie pojedyncza tabela partycjonowana. Żeby wydajnie odpytać zakres dat, używasz tabeli z symbolem wieloznacznym (`events_*`) i filtrujesz po `_TABLE_SUFFIX`, co ogranicza, ile tabel dziennych skanuje BigQuery, i trzyma koszty zapytań w ryzach.

### event_timestamp jest w sekundach czy milisekundach?
W żadnym, jest w mikrosekundach od epoki Uniksa. Żeby zamienić go na czytelny znacznik czasu, użyj `TIMESTAMP_MICROS(event_timestamp)` albo podziel przez 1 000 000, jeśli potrzebujesz sekund. Traktowanie go jak milisekund to częsty błąd, który wrzuca Twoje zdarzenia tysiące lat w przyszłość.

---

*Written by [Piotr Litwa](https://piotrlitwa.com/about.html) - independent GTM & Analytics specialist.*
