Creating an empty dictionary in Python takes one pair of braces:
scores = {} # the usual way
totals = dict() # identical result
Both give you a real dictionary with no keys in it. The braces are the common choice, and they are marginally faster, but the two are interchangeable.
What follows covers filling it afterwards, starting one with keys already in place, and the three ways an empty dictionary can misbehave. Output is from a run on Python 3.12.5.

Declare an empty dictionary with {} or dict()
The two spellings produce exactly the same object:
# the literal - this is the one to use
scores = {}
# the constructor - identical result
totals = dict()
print("scores ->", scores, type(scores))
print("totals ->", totals, type(totals))
print("equal? ->", scores == totals)
print("length ->", len(scores))
print()
print("both are empty dictionaries; there is no third kind.")
Output:
scores -> {} <class 'dict'>
totals -> {} <class 'dict'>
equal? -> True
length -> 0
both are empty dictionaries; there is no third kind.

{} and dict() both build an empty dictionary.There is no separate step to declare or initialise a dictionary in Python. Assigning {} to a name is the whole thing, and the dictionary grows as you add keys.
Is {} faster than dict() for an empty dictionary?
Slightly, and for a reason worth knowing:
import timeit
print("building 5,000,000 empty dictionaries:")
print(f" {{}} {timeit.timeit('{}', number=5000000):.3f}s")
print(f" dict() {timeit.timeit('dict()', number=5000000):.3f}s")
print()
print("the literal is quicker because it is a single bytecode instruction,")
print("while dict() is a name lookup followed by a function call.")
print()
print("dict() still earns its place when you are building from something:")
print(" dict(a=1, b=2) ->", dict(a=1, b=2))
print(" dict([('a', 1), ('b', 2)]) ->", dict([("a", 1), ("b", 2)]))
Output:
building 5,000,000 empty dictionaries:
{} 0.086s
dict() 0.161s
the literal is quicker because it is a single bytecode instruction,
while dict() is a name lookup followed by a function call.
dict() still earns its place when you are building from something:
dict(a=1, b=2) -> {'a': 1, 'b': 2}
dict([('a', 1), ('b', 2)]) -> {'a': 1, 'b': 2}
{} compiles to a single instruction. dict() has to look the name up and call it, which is why it trails on a tight loop.
The gap is far too small to matter in ordinary code. Use dict() freely when you are building from keyword arguments or a list of pairs, since that is what it is good at.
Why {} is not an empty set
This trips people up because the braces are shared between two types:
maybe_a_set = {}
print("empty curly braces give you a DICTIONARY:")
print(" {} ->", maybe_a_set, type(maybe_a_set))
print()
print("an empty set needs the constructor:")
print(" set() ->", set(), type(set()))
print()
print(" {} == set() ->", {} == set())
print()
print("once there is something inside, the braces are unambiguous:")
print(" {1, 2} ->", {1, 2}, type({1, 2}))
print(" {'a': 1} ->", {"a": 1}, type({"a": 1}))
Output:
empty curly braces give you a DICTIONARY:
{} -> {} <class 'dict'>
an empty set needs the constructor:
set() -> set() <class 'set'>
{} == set() -> False
once there is something inside, the braces are unambiguous:
{1, 2} -> {1, 2} <class 'set'>
{'a': 1} -> {'a': 1} <class 'dict'>

set().Dictionaries got the braces first, so {} means an empty dictionary and set() is the only way to write an empty set. Once there is content inside, the comma or colon tells Python which you meant.
How to add to an empty dictionary
The obvious next question, and there are three useful ways:
inventory = {}
# one key at a time
inventory["apples"] = 10
inventory["pears"] = 4
# several at once
inventory.update({"plums": 7, "figs": 2})
# only if the key is not already there
inventory.setdefault("apples", 999)
inventory.setdefault("dates", 5)
print("inventory ->", inventory)
print()
print("setdefault left apples at 10 because the key already existed,")
print("and it added dates because that one did not.")
print()
print("building one inside a loop:")
counts = {}
for fruit in ["apple", "pear", "apple"]:
counts[fruit] = counts.get(fruit, 0) + 1
print("counts ->", counts)
Output:
inventory -> {'apples': 10, 'pears': 4, 'plums': 7, 'figs': 2, 'dates': 5}
setdefault left apples at 10 because the key already existed,
and it added dates because that one did not.
building one inside a loop:
counts -> {'apple': 2, 'pear': 1}
Square bracket assignment adds or overwrites. update() merges several at once. setdefault() only writes when the key is absent, which makes it handy for building up counts and groups.
The get(key, 0) pattern in that loop avoids a KeyError on the first sighting of a word. There is more in adding items to a dictionary.
Create a dictionary with keys but no values
Sometimes what you want is the keys laid out in advance:
keys = ["name", "email", "phone"]
blank = dict.fromkeys(keys)
print("dict.fromkeys(keys) ->", blank)
print("every value is None until you fill it in")
print()
zeros = dict.fromkeys(keys, 0)
print("dict.fromkeys(keys, 0) ->", zeros)
print()
print("a comprehension does the same and is easier to adapt:")
print("{k: 0 for k in keys} ->", {k: 0 for k in keys})
Output:
dict.fromkeys(keys) -> {'name': None, 'email': None, 'phone': None}
every value is None until you fill it in
dict.fromkeys(keys, 0) -> {'name': 0, 'email': 0, 'phone': 0}
a comprehension does the same and is easier to adapt:
{k: 0 for k in keys} -> {'name': 0, 'email': 0, 'phone': 0}
dict.fromkeys() sets every value to None unless you pass a default. Strictly this is not an empty dictionary any more, because it has keys, so a test like if not d: will report it as non-empty.
To find out whether a dictionary really has nothing in it, see checking if a dictionary is empty.
Why dict.fromkeys shares one list between keys
Passing a mutable default to fromkeys() does something people rarely expect:
keys = ["a", "b"]
shared = dict.fromkeys(keys, [])
shared["a"].append(1)
print("dict.fromkeys(keys, []) then shared['a'].append(1):")
print(" shared ->", shared)
print(" b changed too, because both keys point at ONE list")
print(" shared['a'] is shared['b'] ->", shared["a"] is shared["b"])
print()
separate = {k: [] for k in keys}
separate["a"].append(1)
print("a comprehension builds a NEW list per key:")
print(" separate ->", separate)
print(" separate['a'] is separate['b'] ->", separate["a"] is separate["b"])
print()
print("the same applies to {} , set() or any other mutable default.")
Output:
dict.fromkeys(keys, []) then shared['a'].append(1):
shared -> {'a': [1], 'b': [1]}
b changed too, because both keys point at ONE list
shared['a'] is shared['b'] -> True
a comprehension builds a NEW list per key:
separate -> {'a': [1], 'b': []}
separate['a'] is separate['b'] -> False
the same applies to {} , set() or any other mutable default.

fromkeys evaluates the default once, so every key shares one list.The default is evaluated a single time and then handed to every key. Appending to one therefore appears to change all of them, because there was only ever one list.
A dictionary comprehension runs the expression again for each key, so every key gets its own list.
Use fromkeys for immutable defaults such as 0 or None, and a comprehension for anything you plan to modify. Initialising a dictionary with keys and values has more of these patterns.
clear() versus assigning a new empty dict
These look equivalent and are not:
settings = {"theme": "dark"}
backup = settings # both names, one dictionary
settings.clear()
print("after settings.clear():")
print(" settings ->", settings)
print(" backup ->", backup, " the same object was emptied")
print()
settings = {"theme": "dark"}
backup = settings
settings = {}
print("after settings = {}:")
print(" settings ->", settings)
print(" backup ->", backup, " a NEW dict was made; backup kept the old one")
print()
print("clear() empties what everyone is looking at.")
print("Rebinding only changes where this one name points.")
Output:
after settings.clear():
settings -> {}
backup -> {} the same object was emptied
after settings = {}:
settings -> {}
backup -> {'theme': 'dark'} a NEW dict was made; backup kept the old one
clear() empties what everyone is looking at.
Rebinding only changes where this one name points.
