From the PyOTP docs:
At minimum, application implementers should follow this checklist:
...
- Deny replay attacks by rejecting one-time passwords that have been used by the client (this requires storing the most recently authenticated timestamp, OTP, or hash of the OTP in your database, and rejecting the OTP when a match is seen)
...
So when using a TOTP, you have to store something. There's no way around that.
Also note that 30s is a pretty short time for email-based OTPs. If the mail server is only a bit slow, the OTP will have expired.
There is an alternative approach for verifying email addresses that doesn't require you to store anything. In the email, include a link with a query parameter that is encrypted using a key that only your server knows. In the encrypted data, include the email address and registration timestamp. The server decrypts this, checks that the timestamp is still within acceptable range, and adds the email address the database.
Theoretically, there's not even a reason to encrypt; signing would serve the purpose. But the client has no need to know the URL contents, so we might as well not reveal it.