Question: I tried to apply martineau solution in this post to a slightly different case, but it seems that for some obscure (at least to me) reason, the custom encoder isn't called from the json.dump() method. from collections.abc import MutableMapping import json import numpy as np class JSONSerializer(json.JSONEncoder): def encode(self, obj): # Convert dictionary keys that are tuples into strings. if isinstance(obj, MutableMapping): for key in list(obj.keys()): if isinstance(key, tuple): strkey = "%d:%d" % key obj[strkey] = obj.pop(key) return super().encode(obj) class Agent(object): def __init__(self, states, alpha=0.15, random_factor=0.2): self.state_history = [((0, 0), 0)] # state, reward self.alpha = alpha self.random_factor = random_factor # start the rewards table self.G = {} self.init_reward(states) def init_reward(self, states): for i, row in enumerate(states): for j, col in enumerate(row): self.G[(j,i)] = np.random.uniform(high=1.0, low=0.1) def memorize(self): with open("memory.json", "w") as w: json.dump(self.G, w, cls=JSONSerializer) if __name__ == "__main__": robot = Agent(states=np.zeros((6, 6)), alpha=0.1, random_factor=0.25) print(robot.G) robot.memorize()  When testing the string encoding it seems to return what I'm expecting, but when the call to json.dump happens, the custom encoder doesn't seem to be called at all. Do you have any idea why? Thanks

Answer: This should probably be considered an implementation detail of the json package, and also it might be a Python version thing. In any case, it becomes crucial with your implementation:  The dump() function internally calls the iterencode() method on your encoder (see lines 169 and 176 in the actual source code. Yet, the dumps() function internally calls encode() (see lines 231 and 238).  You can verify this by adjusting encode() and overriding iterencode() in your JSONSerializer like so: class JSONSerializer(json.JSONEncoder): def encode(self, obj): print("encode called") ... # Your previous code here return super().encode(obj) def iterencode(self, *args, **kwargs): print("iterencode called") return super().iterencode(*args, **kwargs)  … and you will see that only "iterencode called" will be printed with your test code, but not "encode called". The other Stack Overflow question that you linked in your question seems to have the same issue by the way, at least when using a rather recent version of Python (I am currently on 3.11 for writing this) – see my comment to the corresponding answer. I have two solutions:  Either use dumps() in your Agent.memorize() method, e.g. like so: def memorize(self): with open("memory.json", "w") as w: w.write(json.dumps(self.G, cls=JSONSerializer))   Or move your own implementation from encode() to iterencode(), e.g. like so: class JSONSerializer(json.JSONEncoder): def iterencode(self, obj, *args, **kwargs): if isinstance(obj, MutableMapping): for key in list(obj.keys()): if isinstance(key, tuple): strkey = "%d:%d" % key obj[strkey] = obj.pop(key) yield from super().iterencode(obj, *args, **kwargs)  This 2nd solution seems to have the benefit that it works with both dump() and dumps() (see note below).  A note for completion: The dumps() function later seems to result in a call of iterencode(), as well (I did not track the source code so far as to see where exactly that happens, but from the printouts that I added it definitely happens). This has the following effects: (1) In the 1st proposed solution, as encode() is called first and we can make all adjustments for making our data JSON-serializable there, at this later point, calling iterencode() will not result in an error, any more. (2) In the 2nd proposed solution, as we reimplemented iterencode(), our data will be made JSON-serializable at this point. Update: There are actually two more solutions. First, thanks to the comment of @AbdulAzizBarkat: we can override the default() method. However, we have to make sure that we hand over an object type for serialization that is not handled by the regular encoder, or otherwise we will never reach the default() method with it. In the given code, we can for example pass the Agent instance itself to dump() or dumps(), rather than its dictionary field G. So we have to make two adjustments:  Adjust memorize() to pass self, rather than self.G, e.g. like so: def memorize(self): with open("memory.json", "w") as w: json.dump(self, w, cls=JSONSerializer)   Adjust JSONSerializer.default() to handle the Agent instance, e.g. like so (we won't need encode() and iterencode(), any more): class JSONSerializer(json.JSONEncoder): def default(self, obj): if isinstance(obj, Agent): new_obj = {} for key in obj.G.keys(): new_key = ("%d:%d" % key) if isinstance(key, tuple) else key new_obj[new_key] = obj.G[key] return new_obj # Return the adjusted dictionary return super().default(obj)    Second, arguably the most simple solution is not using a custom JSONEncoder at all, but providing json.dump() with a JSON-serializable object directly. We can do this, for example, by moving the preprocessing of keys to Agent.memorize(): def memorize(self): obj = {} for key in self.G.keys(): new_key = ("%d:%d" % key) if isinstance(key, tuple) else key obj[new_key] = self.G[key] with open("memory.json", "w") as w: json.dump(obj, w)  Final side note: In the first two solutions, the keys of the original dictionary are altered, following the code in your question. You probably don't want to have this though, as actually your instance should not change just because you dump a copy of it. So you might rather want to create a new dictionary with the altered keys and original values. I took care of this in the 3rd and 4th solution.