As the primary key (id) may not be available if the model instance was not saved to the database yet, I wrote my FileField subclasses which move the file on model save, and a storage subclass which removes the old files.
Storage:
class OverwriteFileSystemStorage(FileSystemStorage):
    def _save(self, name, content):
        self.delete(name)
        return super()._save(name, content)
    def get_available_name(self, name):
        return name
    def delete(self, name):
        super().delete(name)
        last_dir = os.path.dirname(self.path(name))
        while True:
            try:
                os.rmdir(last_dir)
            except OSError as e:
                if e.errno in {errno.ENOTEMPTY, errno.ENOENT}:
                    break
                raise e
            last_dir = os.path.dirname(last_dir)
FileField:
def tweak_field_save(cls, field):
    field_defined_in_this_class = field.name in cls.__dict__ and field.name not in cls.__bases__[0].__dict__
    if field_defined_in_this_class:
        orig_save = cls.save
        if orig_save and callable(orig_save):
            assert isinstance(field.storage, OverwriteFileSystemStorage), "Using other storage than '{0}' may cause unexpected behavior.".format(OverwriteFileSystemStorage.__name__)
            def save(self, *args, **kwargs):
                if self.pk is None:
                    orig_save(self, *args, **kwargs)
                    field_file = getattr(self, field.name)
                    if field_file:
                        old_path = field_file.path
                        new_filename = field.generate_filename(self, os.path.basename(old_path))
                        new_path = field.storage.path(new_filename)
                        os.makedirs(os.path.dirname(new_path), exist_ok=True)
                        os.rename(old_path, new_path)
                        setattr(self, field.name, new_filename)
                    # for next save
                    if len(args) > 0:
                        args = tuple(v if k >= 2 else False for k, v in enumerate(args))
                    kwargs['force_insert'] = False
                    kwargs['force_update'] = False
                orig_save(self, *args, **kwargs)
            cls.save = save
def tweak_field_class(orig_cls):
    orig_init = orig_cls.__init__
    def __init__(self, *args, **kwargs):
        if 'storage' not in kwargs:
            kwargs['storage'] = OverwriteFileSystemStorage()
        if orig_init and callable(orig_init):
            orig_init(self, *args, **kwargs)
    orig_cls.__init__ = __init__
    orig_contribute_to_class = orig_cls.contribute_to_class
    def contribute_to_class(self, cls, name):
        if orig_contribute_to_class and callable(orig_contribute_to_class):
            orig_contribute_to_class(self, cls, name)
        tweak_field_save(cls, self)
    orig_cls.contribute_to_class = contribute_to_class
    return orig_cls
def tweak_file_class(orig_cls):
    """
    Overriding FieldFile.save method to remove the old associated file.
    I'm doing the same thing in OverwriteFileSystemStorage, but it works just when the names match.
    I probably want to preserve both methods if anyone calls Storage.save.
    """
    orig_save = orig_cls.save
    def new_save(self, name, content, save=True):
        self.delete(save=False)
        if orig_save and callable(orig_save):
            orig_save(self, name, content, save=save)
    new_save.__name__ = 'save'
    orig_cls.save = new_save
    return orig_cls
@tweak_file_class
class OverwriteFieldFile(models.FileField.attr_class):
    pass
@tweak_file_class
class OverwriteImageFieldFile(models.ImageField.attr_class):
    pass
@tweak_field_class
class RenamedFileField(models.FileField):
    attr_class = OverwriteFieldFile
@tweak_field_class
class RenamedImageField(models.ImageField):
    attr_class = OverwriteImageFieldFile
and my upload_to callables look like this:
def user_image_path(instance, filename):
    name, ext = 'image', os.path.splitext(filename)[1]
    if instance.pk is not None:
        return os.path.join('users', os.path.join(str(instance.pk), name + ext))
    return os.path.join('users', '{0}_{1}{2}'.format(uuid1(), name, ext))