OK, the first load is expected, because when you session.query(X).options(someload(X.y)), the query will load all the ".y" for objects that it finds already in the identity map, however it will not establish someload() as a new permanent option on that X going forward, so subsequent loads use the default loader. if session.query(X).options(someload(X.y)) actually created the new X, then the someload() option is permanently associated with it. I understand this is not completely clear but historically, all of these loader options were just optimizations and it wasn't the end of the world if in some refresh circumstances, they didn't take effect. however with lazy='raise' it's a much bigger source of confusion.
There's an additional issue which is that in current 1.3 series, the call to refresh() does not make use of the loaders that were applied to the object . So for current releases, it still wont work for you; the load after the refresh() will fail even if the X was just loaded. However for 1.4 , it will work, due to
https://docs.sqlalchemy.org/en/14/changelog/migration_14.html#change-1763 .
short answer is that in 1.3 if you are using lazy="raise", the object won't really have flexibility in being able to actually load that attribute other than accessing it with a query. if you want, you can use this pattern, which should work everywhere:
session.query(Group).populate_existing().options(lazyload(Group.users)).get(
group.id)
that also will apply the lazyload() option to the Group object permanently unless populate_existing() is used on it again.