pandas DataFrame() — build a table from dicts, lists, arrays, and Series

Every pandas session starts with the same five parameters: data, index, columns, dtype, copy — learn what each one does to your data.

The pandas constructor predates the 0.1 release, had just three parameters, and its docstring warned that data is COPIED — in all caps.

What it does

pd.DataFrame(data, index, columns, dtype, copy) turns almost any tabular-ish Python object into a DataFrame. The shape of `data` decides the layout: a list of dicts becomes one row per dict (missing keys become NaN), a dict of lists or Series becomes one column per key, a 2D NumPy array needs columns= to get names, and a dict with tuple keys builds MultiIndex columns for free. index and columns override or supply the labels; dtype forces one dtype across everything (ints become floats); copy=False asks pandas to share memory with the input instead of duplicating it.

Why it matters

Everything else in pandas — query, merge, groupby — operates on a DataFrame, so the constructor is the one API you cannot skip. In real work it shows up as: assembling a report from rows pulled off an API (list of dicts), aligning two sensors that logged different hours (dict of Series, which union-aligns the index), giving a NumPy matrix from legacy code real column names, and hard-typing columns before a Parquet write so downstream schemas stay stable. Get the constructor's rules right once and every ingestion path after it behaves predictably.

Examples

A customer API returns orders as JSON rows. The natural ingestion is a list of dicts — one dict per row, keys become columns:
   order_id    city  revenue
0      1001  Berlin    144.0
1      1002  Vienna     55.3
2      1003  Berlin    384.0
order_id      int64
city            str
revenue     float64
dtype: object

pd.DataFrame([{'order_id': 1001, 'city': 'Berlin', 'revenue': 144.0}, ...]) with RangeIndex 0..2. Note the dtypes: pandas 3.0 gives text columns the new 'str' dtype, and a record that omits a key (say Vienna has no revenue) silently becomes NaN — the constructor never guesses.

Two machine sensors logged different hours; you want one aligned table anyway. A dict of Series union-aligns the index and fills the gaps:
        price  units
berlin  810.0   12.0
vienna  640.0    NaN
zurich    NaN    9.0

pd.DataFrame({'price': s2, 'units': s1}) — vienna has no units reading and berlin has no price reading, so NaN appears exactly where data is missing. This alignment is a feature, not a bug: it is the same rule SQL FULL OUTER JOIN uses, built into construction. Feed a named index for readable row labels: pd.DataFrame({'temp_c': [...], 'rpm': [...]}, index=pd.Index(['mon','tue','wed'], name='day')).

Legacy code hands you a bare NumPy matrix plus you need forced column names — or you want a column order you choose yourself:
    x  y  z
r1  1  2  3
r2  4  5  6

pd.DataFrame(np.array([[1,2,3],[4,5,6]]), index=['r1','r2'], columns=['x','y','z']) — with 2D arrays, columns= is how the matrix gets names. Verified also: pd.DataFrame(rec) on a structured/record array adopts the field names as columns automatically (order_id, city, revenue), and pd.DataFrame({'temp_c': [21.5, 22.1], 'rpm': [1420, 1390]}, dtype='float64') upcasts the int rpm column to float — passing dtype forces every column.

Flags

FlagMeaning
datandarray, dict of arrays/Series, list of dicts, list of lists, another DataFrame — the input shape sets the layout
index=...row labels; supply with ndarray/list data, or conform/override existing index
columns=...column names; required (or at least useful) for bare arrays, reorderable for dict input
dtype='float64'force one dtype everywhere; int columns upcast, strings stay strings
copy=Falseshare input data instead of copying — safe under pandas 3.0's Copy-on-Write, but writes to either object then trigger a copy
list of Seriespd.DataFrame([s1, s2]) treats each Series as a ROW; the dict form {'a': s1} treats each Series as a column — the most common constructor mix-up
scalar broadcastpd.DataFrame({'city': ['Berlin', 'Vienna'], 'country': 'DE'}) — a scalar fills the whole column

Day one of the project (2009)

pandas/core/frame.py with the DataFrame class was in Wes McKinney's very first SVN-import commit, "first commit with cleaned up code", dated August 5, 2009 (git-svn revision @5 on Google Code, four months before 0.1.0 hit PyPI on December 25). Back then the signature was just data, index, columns — no dtype, no copy — and every column was a Series internally.

The alternate-constructor family

The 2009 class already carried classmethod helpers: fromDict (a two-level {col: {idx: val}} tree — the ancestor of today's from_dict) and from_records. They were added because the bare constructor couldn't disambiguate every input shape; fifteen years later pd.DataFrame.from_dict(..., orient='index') is still the sanctioned way to build row-keyed data.

Under the hood: one funnel, five doors

Everything lands in pandas.core.frame.DataFrame.__init__, which dispatches on type: dict paths go through _from_dict (dicts of Series get union-aligned index arithmetic, tuple keys build a MultiIndex), ndarray/list paths through _from_arrays or _from_records, and a passed DataFrame re-uses its manager via copy_or_deepcopy semantics. Since 2.x the result is wrapped in a DataFrameManager of column blocks; under Copy-on-Write (default since 3.0) copy=False can hand you a zero-copy view that only materializes a copy on first write — which is why the old all-caps COPIED warning is gone from the docstring.

Fun facts

Pros

Cons

Takeaways