Dacă lucrezi cu date în Python, mai devreme sau mai târziu o să te lovești de scenariul ăsta: ai datele de care ai nevoie, dar sunt împărțite în mai multe tabele. Poate ai un DataFrame cu angajații și altul cu salariile. Sau un set cu comenzile clienților și altul cu detaliile produselor.
Cum le combini eficient? Ei bine, Pandas oferă trei instrumente principale: merge(), join() și concat(). Fiecare servește un scop diferit, iar alegerea corectă poate face diferența între un cod clar, performant și unul confuz și lent. În ghidul de față, le trecem pe toate trei prin exemple practice, comparații și sfaturi actualizate pentru Pandas 3.0 (lansat în ianuarie 2026).
Dacă nu ești la zi cu schimbările din Pandas 3.0, îți recomand să arunci un ochi mai întâi pe ghidul nostru de migrare la Pandas 3.0 — multe dintre conceptele de acolo se aplică direct la ce discutăm aici.
Pregătirea Mediului de Lucru
Înainte de orice, să ne asigurăm că avem totul pregătit. Vom folosi Pandas 3.0 cu backend-ul PyArrow — configurația recomandată în 2026:
import pandas as pd
import numpy as np
print(f"Pandas version: {pd.__version__}") # 3.0.x
# Creăm câteva DataFrame-uri de lucru
angajati = pd.DataFrame({
'id_angajat': [1, 2, 3, 4, 5],
'nume': ['Ana Popescu', 'Mihai Ionescu', 'Elena Dumitrescu', 'Andrei Popa', 'Maria Stan'],
'departament': ['IT', 'HR', 'IT', 'Vânzări', 'Marketing'],
'oras': ['București', 'Cluj-Napoca', 'Iași', 'Timișoara', 'București']
})
salarii = pd.DataFrame({
'id_angajat': [1, 2, 3, 4, 6],
'salariu': [8500, 7200, 9100, 6800, 7500],
'bonus': [1200, 800, 1500, 600, 900]
})
proiecte = pd.DataFrame({
'id_angajat': [1, 1, 2, 3, 5],
'proiect': ['Website', 'API', 'Recrutare', 'Website', 'Campanie'],
'ore_lucrate': [120, 80, 200, 150, 90]
})
print(angajati)
print(salarii)
print(proiecte)
Observă că datele sunt distribuite în trei tabele separate — fix cum le-ai găsi într-o bază de date relațională. Angajatul cu id_angajat=5 apare în tabelul angajaților dar nu și în salarii, iar id_angajat=6 e în salarii dar nu în angajați. Aceste inconsistențe sunt intenționate — reflectă realitatea datelor cu care lucrăm zilnic și ne vor ajuta să înțelegem diferențele dintre tipurile de join.
pd.merge() — Combinarea în Stil SQL
merge() e cel mai puternic și flexibil instrument de combinare din Pandas. Funcționează exact ca un JOIN din SQL: combină două DataFrame-uri pe baza uneia sau mai multor coloane comune (cheile de join).
Inner Join — Intersecția datelor
Implicit, merge() efectuează un inner join — adică păstrează doar rândurile care au corespondent în ambele tabele.
# Inner join — doar angajații care au și salariu
rezultat = pd.merge(angajati, salarii, on='id_angajat')
print(rezultat)
# Echivalent cu:
# SELECT * FROM angajati INNER JOIN salarii ON angajati.id_angajat = salarii.id_angajat
Rezultatul conține doar 4 rânduri — angajații cu id-urile 1, 2, 3, 4. Maria Stan (id=5) lipsește pentru că nu are salariu, iar angajatul 6 lipsește fiindcă nu e în tabelul angajaților. Destul de intuitiv, nu?
Left Join — Toate datele din stânga
Left join-ul păstrează toate rândurile din DataFrame-ul din stânga și completează cu NaN acolo unde nu există corespondent în dreapta:
# Left join — toți angajații, cu sau fără salariu
rezultat_left = pd.merge(angajati, salarii, on='id_angajat', how='left')
print(rezultat_left)
# Maria Stan (id=5) apare cu NaN la salariu și bonus
# Angajatul 6 nu apare deloc (nu e în tabelul din stânga)
Right Join — Toate datele din dreapta
# Right join — toate salariile, chiar dacă angajatul nu e în tabelul principal
rezultat_right = pd.merge(angajati, salarii, on='id_angajat', how='right')
print(rezultat_right)
# Angajatul 6 apare cu NaN la nume, departament și oraș
# Maria Stan (id=5) nu apare (nu e în tabelul salariilor)
Outer Join — Uniunea completă
# Outer join — toți angajații și toate salariile
rezultat_outer = pd.merge(angajati, salarii, on='id_angajat', how='outer')
print(rezultat_outer)
# Conține 6 rânduri: toți angajații + angajatul 6
# NaN apare acolo unde datele lipsesc
Anti Join — Noutate în Pandas 3.0
Sincer, asta e una dintre cele mai utile noutăți din Pandas 3.0. Un anti join returnează doar rândurile care nu au corespondent în celălalt tabel — extrem de util pentru detectarea datelor lipsă sau inconsistente:
# Left anti join — angajații FĂRĂ salariu (noutate Pandas 3.0!)
fara_salariu = pd.merge(angajati, salarii, on='id_angajat', how='left_anti')
print(fara_salariu)
# Rezultat: doar Maria Stan (id=5) — singurul angajat fără salariu
# Right anti join — salariile fără angajat corespunzător
salarii_orfane = pd.merge(angajati, salarii, on='id_angajat', how='right_anti')
print(salarii_orfane)
# Rezultat: doar angajatul cu id=6 — salariu fără angajat
Înainte de Pandas 3.0, pentru același rezultat trebuia să folosești un outer join cu indicator=True și apoi să filtrezi manual. O operație în mai mulți pași, mai puțin intuitivă și (să fim sinceri) destul de enervantă:
# Metoda veche (pre-Pandas 3.0) — evitați în codul nou
merge_indicator = pd.merge(angajati, salarii, on='id_angajat', how='outer', indicator=True)
fara_salariu_vechi = merge_indicator[merge_indicator['_merge'] == 'left_only']
print(fara_salariu_vechi)
# Acum puteți folosi direct how='left_anti' — mult mai clar!
Cross Join — Produsul cartezian
# Cross join — fiecare angajat combinat cu fiecare proiect
culori = pd.DataFrame({'culoare': ['roșu', 'albastru', 'verde']})
marimi = pd.DataFrame({'marime': ['S', 'M', 'L']})
combinatii = pd.merge(culori, marimi, how='cross')
print(combinatii)
# 9 rânduri: 3 culori × 3 mărimi
Opțiuni Avansate pentru merge()
Merge pe coloane cu nume diferite
Uneori coloana cheie are nume diferite în cele două tabele. Se întâmplă des, mai ales când lucrezi cu date din surse diferite. Parametrii left_on și right_on rezolvă elegant situația:
# Coloane cu nume diferite
evaluari = pd.DataFrame({
'angajat_id': [1, 2, 3, 4],
'scor': [4.5, 4.2, 4.8, 3.9],
'an': [2025, 2025, 2025, 2025]
})
# left_on și right_on pentru coloane cu nume diferite
rezultat = pd.merge(
angajati, evaluari,
left_on='id_angajat',
right_on='angajat_id'
)
print(rezultat)
# Notă: ambele coloane de cheie apar în rezultat (id_angajat + angajat_id)
Merge pe mai multe coloane
# Merge pe mai multe chei — simulăm date trimestriale
vanzari_t1 = pd.DataFrame({
'produs': ['Laptop', 'Laptop', 'Telefon', 'Telefon'],
'regiune': ['Nord', 'Sud', 'Nord', 'Sud'],
'cantitate': [100, 150, 200, 180]
})
tinta_vanzari = pd.DataFrame({
'produs': ['Laptop', 'Laptop', 'Telefon'],
'regiune': ['Nord', 'Sud', 'Nord'],
'tinta': [120, 140, 250]
})
comparatie = pd.merge(vanzari_t1, tinta_vanzari, on=['produs', 'regiune'], how='left')
comparatie['procent_realizare'] = (comparatie['cantitate'] / comparatie['tinta'] * 100).round(1)
print(comparatie)
Gestionarea coloanelor duplicate cu suffixes
Când ambele tabele au coloane cu același nume (altele decât cheile de join), Pandas adaugă automat sufixe. Poți personaliza aceste sufixe pentru a face rezultatul mai ușor de citit:
# Coloane duplicate
evaluari_2024 = pd.DataFrame({
'id_angajat': [1, 2, 3],
'scor': [4.2, 4.0, 4.5]
})
evaluari_2025 = pd.DataFrame({
'id_angajat': [1, 2, 3],
'scor': [4.5, 4.2, 4.8]
})
# Sufixe personalizate
comparatie_evaluari = pd.merge(
evaluari_2024, evaluari_2025,
on='id_angajat',
suffixes=('_2024', '_2025')
)
comparatie_evaluari['diferenta'] = comparatie_evaluari['scor_2025'] - comparatie_evaluari['scor_2024']
print(comparatie_evaluari)
Parametrul indicator pentru debugging
# indicator=True pentru a vedea sursa fiecărui rând
debug_merge = pd.merge(
angajati, salarii,
on='id_angajat',
how='outer',
indicator=True
)
print(debug_merge)
# Coloana _merge arată: 'both', 'left_only' sau 'right_only'
# Sumar rapid al potrivirilor
print(debug_merge['_merge'].value_counts())
Validarea relațiilor cu validate
Parametrul validate e unul din acele lucruri pe care nu le apreciezi până nu te salvează de ore întregi de debugging. Previne erorile silențioase cauzate de duplicate neașteptate:
# Verificăm că relația este one-to-one
try:
pd.merge(angajati, salarii, on='id_angajat', validate='one_to_one')
print("Validare one-to-one reușită!")
except pd.errors.MergeError as e:
print(f"Eroare de validare: {e}")
# Opțiuni validate: 'one_to_one', 'one_to_many', 'many_to_one', 'many_to_many'
pd.concat() — Stivuirea DataFrame-urilor
Spre deosebire de merge(), care combină tabele pe baza cheilor comune, concat() face ceva mult mai simplu: stivuiește DataFrame-uri — fie pe verticală (rânduri peste rânduri), fie pe orizontală (coloane lângă coloane).
Concatenare verticală (axis=0)
# Date din trei trimestre diferite
q1 = pd.DataFrame({
'luna': ['Ianuarie', 'Februarie', 'Martie'],
'vanzari': [15000, 18000, 22000]
})
q2 = pd.DataFrame({
'luna': ['Aprilie', 'Mai', 'Iunie'],
'vanzari': [20000, 25000, 28000]
})
q3 = pd.DataFrame({
'luna': ['Iulie', 'August', 'Septembrie'],
'vanzari': [26000, 24000, 30000]
})
# Concatenare verticală — stivuire pe rânduri
an_complet = pd.concat([q1, q2, q3], ignore_index=True)
print(an_complet)
# 9 rânduri — toate trimestrele într-un singur DataFrame
Parametrul keys pentru trasabilitate
# keys — pentru a ști din ce DataFrame provine fiecare rând
an_cu_sursa = pd.concat(
[q1, q2, q3],
keys=['T1', 'T2', 'T3'],
names=['trimestru', 'index_original']
)
print(an_cu_sursa)
# Acces la un trimestru specific
print(an_cu_sursa.loc['T2'])
Concatenare orizontală (axis=1)
# Concatenare orizontală — adăugare de coloane
info_personala = pd.DataFrame({
'nume': ['Ana', 'Mihai', 'Elena'],
'varsta': [28, 35, 31]
}, index=['A', 'B', 'C'])
info_profesionala = pd.DataFrame({
'departament': ['IT', 'HR', 'IT'],
'experienta_ani': [5, 10, 7]
}, index=['A', 'B', 'C'])
profil_complet = pd.concat([info_personala, info_profesionala], axis=1)
print(profil_complet)
Gestionarea coloanelor diferite
# DataFrame-uri cu coloane parțial diferite
df_ro = pd.DataFrame({
'oras': ['București', 'Cluj'],
'populatie': [1800000, 320000],
'tara': ['România', 'România']
})
df_md = pd.DataFrame({
'oras': ['Chișinău'],
'populatie': [700000],
'moneda': ['MDL']
})
# join='outer' (implicit) — păstrează toate coloanele
combinat_outer = pd.concat([df_ro, df_md], ignore_index=True)
print(combinat_outer)
# Coloanele 'tara' și 'moneda' vor avea NaN unde lipsesc
# join='inner' — doar coloanele comune
combinat_inner = pd.concat([df_ro, df_md], join='inner', ignore_index=True)
print(combinat_inner)
# Doar coloanele 'oras' și 'populatie'
Performanță: evitați concat() în bucle
Asta e probabil cel mai frecvent anti-pattern pe care îl văd la începători (și nu numai). Apelarea concat() într-o buclă creează o copie completă a datelor la fiecare iterație, ceea ce degradează dramatic performanța:
# ❌ GREȘIT — concat() în buclă (extrem de lent)
rezultat = pd.DataFrame()
for i in range(100):
df_nou = pd.DataFrame({'valoare': [i]})
rezultat = pd.concat([rezultat, df_nou])
# ✅ CORECT — colectare în listă, apoi un singur concat()
lista_df = []
for i in range(100):
lista_df.append(pd.DataFrame({'valoare': [i]}))
rezultat = pd.concat(lista_df, ignore_index=True)
# ✅ ȘI MAI BINE — list comprehension
rezultat = pd.concat(
[pd.DataFrame({'valoare': [i]}) for i in range(100)],
ignore_index=True
)
DataFrame.join() — Combinare pe Index
Metoda join() e practic un shortcut pentru merge(), optimizat pentru combinarea pe baza indexului. Intern apelează tot merge(), dar cu o sintaxă mai concisă. Dacă ai deja indexul setat pe coloanele potrivite, join() e alegerea naturală.
Join pe index
# Setăm index-ul pe id_angajat
angajati_idx = angajati.set_index('id_angajat')
salarii_idx = salarii.set_index('id_angajat')
# join() pe index — implicit left join
rezultat_join = angajati_idx.join(salarii_idx)
print(rezultat_join)
# Echivalent cu:
# pd.merge(angajati, salarii, left_index=True, right_index=True, how='left')
Join pe o coloană specifică
# Join cu parametrul on — coloana din stânga se potrivește cu indexul din dreapta
rezultat_on = angajati.join(salarii_idx, on='id_angajat')
print(rezultat_on)
Join multiplu — combinarea mai multor DataFrame-uri
# Combinarea mai multor DataFrame-uri simultan
proiecte_idx = proiecte.groupby('id_angajat')[['ore_lucrate']].sum()
proiecte_idx.columns = ['total_ore']
evaluari_idx = pd.DataFrame({
'scor_evaluare': [4.5, 4.2, 4.8, 3.9]
}, index=[1, 2, 3, 4])
evaluari_idx.index.name = 'id_angajat'
# join() acceptă o listă de DataFrame-uri
profil_complet = angajati_idx.join([salarii_idx, proiecte_idx, evaluari_idx])
print(profil_complet)
merge() vs join() vs concat() — Când Folosim Ce?
Bun, deci avem trei metode. Când folosim pe care? Alegerea depinde de structura datelor și de ce vrei să faci cu ele:
# Rezumat vizual al alegerii corecte:
#
# ┌─────────────────────────────────────────────────────┐
# │ Cum vrei să combini datele? │
# ├──────────┬──────────────┬───────────────────────────┤
# │ Pe baza │ Pe baza │ Stivuire rânduri/coloane │
# │ coloane │ indexului │ (fără cheie) │
# │ comune │ │ │
# ├──────────┼──────────────┼───────────────────────────┤
# │ merge() │ join() │ concat() │
# │ │ sau merge() │ │
# │ │ cu │ │
# │ │ left_index/ │ │
# │ │ right_index │ │
# └──────────┴──────────────┴───────────────────────────┘
Pe scurt:
merge()— Cel mai flexibil. Folosește-l când combini pe coloane comune (la fel ca un SQL JOIN). Suportă inner, left, right, outer, cross, left_anti și right_anti. Implicit face inner join.join()— Shortcut pentru merge pe index. Mai concis când indexul e deja setat. Implicit face left join. Poate combina mai multe DataFrame-uri odată.concat()— Stivuiește DataFrame-uri pe verticală sau orizontală. Nu necesită cheie comună. Ideal pentru combinarea datelor din surse similare (fișiere lunare, rapoarte trimestriale etc.).
Scenarii Reale și Exemple Practice
Scenariul 1: Raport complet de angajați
Hai să vedem cum arată un caz real — combinăm toate datele într-un raport complet:
# Combinăm toate datele într-un raport complet
raport = (
angajati
.merge(salarii, on='id_angajat', how='left')
.merge(
proiecte.groupby('id_angajat').agg(
nr_proiecte=('proiect', 'count'),
total_ore=('ore_lucrate', 'sum')
),
on='id_angajat',
how='left'
)
)
# Completăm valorile lipsă
raport['nr_proiecte'] = raport['nr_proiecte'].fillna(0).astype(int)
raport['total_ore'] = raport['total_ore'].fillna(0).astype(int)
print(raport)
Scenariul 2: Combinarea fișierelor CSV lunare
import glob
# Citirea și combinarea tuturor fișierelor CSV dintr-un folder
# fisiere_csv = glob.glob('date_lunare/*.csv')
# df_lista = [pd.read_csv(f) for f in sorted(fisiere_csv)]
# date_complete = pd.concat(df_lista, ignore_index=True)
# Simulare cu date de exemplu
luni = ['ianuarie', 'februarie', 'martie']
df_lista = []
for luna in luni:
df_luna = pd.DataFrame({
'luna': [luna] * 3,
'produs': ['A', 'B', 'C'],
'vanzari': np.random.randint(100, 500, 3)
})
df_lista.append(df_luna)
date_complete = pd.concat(df_lista, ignore_index=True)
print(date_complete)
Scenariul 3: Detectarea inconsistențelor între tabele
Aici anti join-urile din Pandas 3.0 strălucesc cu adevărat. Am folosit pattern-ul ăsta recent la un proiect de audit intern și a simplificat enorm lucrurile:
# Folosim anti join (Pandas 3.0) pentru audit
# Angajați fără salariu
angajati_fara_salariu = pd.merge(
angajati, salarii,
on='id_angajat',
how='left_anti'
)
print("Angajați fără salariu:")
print(angajati_fara_salariu)
# Salarii fără angajat valid
salarii_orfane = pd.merge(
angajati, salarii,
on='id_angajat',
how='right_anti'
)
print("
Salarii fără angajat:")
print(salarii_orfane)
# Verificare completă cu indicator
audit = pd.merge(
angajati, salarii,
on='id_angajat',
how='outer',
indicator='status_potrivire'
)
print("
Audit complet:")
print(audit[['id_angajat', 'nume', 'salariu', 'status_potrivire']])
Performanță și Bune Practici
Câteva sfaturi testate în practică pentru performanță maximă:
- Folosește tipuri de date categorice pentru coloanele de join cu puține valori unice (departament, țară, status). Conversia la
categoryreduce memoria și accelerează join-urile. - Sortează datele înainte de merge pe coloanele de join — pentru seturi mari, asta poate face o diferență semnificativă.
- Evită concat() în bucle — colectează DataFrame-urile într-o listă și apelează
concat()o singură dată la final. - Folosește
validatepentru a prinde erorile de date devreme. Un merge many-to-many neașteptat poate multiplica rândurile și cauza probleme greu de depanat. - Verifică rezultatul cu
indicator=Truela outer join-uri — vei vedea imediat câte rânduri au corespondent și câte nu.
# Exemplu: optimizare cu tip categoric
angajati['departament'] = angajati['departament'].astype('category')
# Verificare dimensiune înainte și după merge
print(f"Angajați: {len(angajati)} rânduri")
print(f"Salarii: {len(salarii)} rânduri")
rezultat = pd.merge(angajati, salarii, on='id_angajat', how='inner')
print(f"După inner join: {len(rezultat)} rânduri")
# Dacă rezultatul are mai multe rânduri decât ambele tabele,
# probabil aveți duplicate în cheile de join!
assert len(rezultat) <= max(len(angajati), len(salarii)), "Atenție: posibil merge many-to-many neașteptat!"
Copy-on-Write în Pandas 3.0 și Impactul asupra Merge/Join
Odată cu Pandas 3.0, comportamentul Copy-on-Write (CoW) e activat implicit. Ce înseamnă asta concret pentru operațiile de combinare?
- Rezultatul oricărui
merge(),join()sauconcat()se comportă întotdeauna ca o copie independentă — modificarea rezultatului nu afectează DataFrame-urile originale. - Parametrul
copydinmerge()e acum depreciat și ignorat — CoW gestionează automat copierea. - Intern, Pandas folosește un mecanism de lazy copy: datele sunt partajate în memorie până când una dintre copii e modificată. Abia atunci se creează o copie reală, doar pentru porțiunea afectată.
# Copy-on-Write în acțiune
rezultat = pd.merge(angajati, salarii, on='id_angajat')
# Modificarea rezultatului NU afectează tabelele originale
rezultat.loc[0, 'salariu'] = 99999
print(f"Salariu în rezultat: {rezultat.loc[0, 'salariu']}") # 99999
print(f"Salariu original: {salarii.loc[0, 'salariu']}") # 8500 — neschimbat!
# Parametrul copy este ignorat în Pandas 3.0 (depreciat)
# Următoarea linie funcționează, dar copy nu are efect
rezultat2 = pd.merge(angajati, salarii, on='id_angajat', copy=False) # Avertisment depreciere
Tipuri de Date String în Pandas 3.0
Încă o schimbare din Pandas 3.0 care merită menționată: tipul de date implicit pentru stringuri nu mai e object, ci un tip dedicat str (cu backend PyArrow). Asta poate cauza surprize la merge dacă ai un mix de DataFrame-uri create cu versiuni diferite:
# Verificarea tipurilor de date înainte de merge
print("Tipuri angajati:")
print(angajati.dtypes)
print("
Tipuri salarii:")
print(salarii.dtypes)
# Dacă întâmpinați erori de tip la merge, asigurați-vă
# că cheile de join au același tip de date în ambele tabele
# angajati['id_angajat'] = angajati['id_angajat'].astype('int64')
# salarii['id_angajat'] = salarii['id_angajat'].astype('int64')
Întrebări Frecvente (FAQ)
Care este diferența dintre merge() și join() în Pandas?
merge() combină DataFrame-uri pe baza coloanelor comune (ca un SQL JOIN) și implicit face inner join. join() e un shortcut care combină pe baza indexului și implicit face left join. Intern, join() apelează merge(), deci funcționalitatea e echivalentă — diferența principală e sintaxa și valorile implicite.
Cum combini mai mult de două DataFrame-uri în Pandas?
Ai mai multe opțiuni: pd.concat([df1, df2, df3]) pentru stivuire, sau înlănțuire de merge(): df1.merge(df2, on='cheie').merge(df3, on='cheie'). Metoda join() acceptă și o listă de DataFrame-uri: df1.join([df2, df3]).
Ce este un anti join în Pandas 3.0 și când îl folosesc?
Anti join-urile (how='left_anti' și how='right_anti') sunt noi în Pandas 3.0 și returnează rândurile dintr-un tabel care nu au corespondent în celălalt. Sunt ideale pentru auditarea datelor, detectarea înregistrărilor orfane, găsirea clienților fără comenzi și identificarea inconsistențelor între tabele.
De ce merge() generează mai multe rânduri decât tabelele originale?
Se întâmplă când cheile de join conțin valori duplicate în ambele tabele — rezultă un merge many-to-many care produce produsul cartezian al potrivirilor. Folosește validate='one_to_one' sau validate='many_to_one' pentru a detecta asta înainte de a deveni o problemă.
Cum evit degradarea performanței la concat() cu multe DataFrame-uri?
Nu apela concat() în interiorul unei bucle. Colectează toate DataFrame-urile într-o listă Python și apelează pd.concat(lista, ignore_index=True) o singură dată la final. Diferența de performanță poate fi de zeci de ori — mai ales pe seturi mari de date — fiindcă eviți copierea repetată a datelor.