Skip to main content
32 Popular Rails Gems:

Soft Delete with Paranoia

Episode 41 · February 5, 2015

Learn how to soft delete records instead of deleting them permanently from your database

Gems ActiveRecord


Today we're going to talk about a gem called Paranoia, and how you can use it to basically archive stuff in your application, or do soft deletes, as they're sometimes called. In previous episodes we've built this forum and we have a bunch of forum threads, and if you click on a thread, there's posts by users, and we keep track of who created what, so the thread was created by me, Chris Oliver, and I made the first post, and then the second post was by "Test user", and we have a few conversations testing different things, and what happens here is that if test user deletes their account, and we can simulate that by

rails c

Once we do that and we refresh the page, this is now broken, because we have the in our view, and what this is saying is "undefined method 'name' for nil:NilClass", which means that we called name on something, and that thing was nil, so forum_post.user is nil. And if we look at the forum post, we have a user id, so it's trying to see that the user exists, and now when we call forum_post.user it does a query for users where ID is 2, and there is none. So we get a nil, and then we say, of course you get the same error as here. How do we go about solving this problem?

There's a handful of different ways you can do it, and one thing that you'll see for example on reddit is to take this and put an if statement around it, so that rather than just putting out the user name, you could say if forum_post.user.present, then we will display the, otherwise we'll just print out something like "Deleted User", and this will return the "Deleted User" string when there is no user otherwise, it will work as we expect, and we can go and update our views to do this, and you can build a helper method for it, or make a presenter to make this a little prettier, but you're introducing if statements in your application, and you have to be aware of that, because that causes more problems down the road. Now we have this where it says "deleted user" and this is updated. But when it comes to things like financial information, and you're recording payments, and things like that, you actually can't just straight up delete your users, because you need their names and email addresses for things like that. So that's something that you have to be very aware of, so what we're going to use in this episode, is a gem called Paranoia. Now there's an old gem called acts-as-paranoid that was rewritten on by a handful of people, and it's a very lightweight gem now, and it's amazing, there's really not much to it. This is all there is, one file of code which is under 225 lines, so that's great, easy to debug and flexible enough. Now this gem basically does soft deletes or archiving, so rather than just straight up deleting the record from your database, you add a field called deleted_at, and if this field is null, then your record is not deleted, but if it does have a timestamp there, then that means that that record was deleted, so then, what happens is, you just query all of your updates to your queries, so if this thread for example had paranoia on it, you could say it was deleted_at whenever, and then you could have the forum thread section only show the ones that aren't deleted. So let's go ahead and add this to our application. I'm going to grab the latest version of the paranoia gem. Let's undo the change that we just made there, and then jump into the bottom of our Gemfile, add in paranoia, and here actually I'm going to go create that user again, so that we can delete them and test things out once more.

User.create email: "[email protected]", first_name: "Test", last_name: "User", password: "password", password_confirmation: "password"

bundle install

We've got our rails server restarted, and now we need to go run a migration to add the deleted_at columns to our models. Let's first do this with forum threads, and we'll see how we can apply that to forum threads in a similar fashion. If we generate a migration

rails g migration AddDeletedAtToForumThreads deleted_at:datetime

rake db:migrate

bundle exec rake db:migrate

because I have multiple versions or rake installed, and now that that's finished, we can hop back over to Chrome and take a look at what it requires to add this to our model. Another thing we want to do is add the index in that I missed, so to do that in development, we can just rake db:rollback, and then we need to roll that with bundle exec rake db:rollback, so we'll rollback that adding deleted_at column, and then we'll open up the migration the migration that we just created, and paste in adding the add_index :forum_threads, deleted_at. This index is important since you're going to be querying for this field all the time. You're going to always look for if it's null or not, basically. This index will help the database index that column so that all your queries are faster, and it's very very important that you add that. So we can take the acts_as_paranoid and put that into our forum_thread.rb. Run bundle exec rake db:migrate, the "be" shortcut is because I'm using zsh, and it comes with a shortcut for that. So now we can go back to our forum threads, and if you look at your documentation when you call destroy, it just sets the deleted_at timestamp, and that's all.

Now we can go into here, and I forgot to create that user apparently, or no, I forgot to update the users id to match the one that we had before, so if we take the last user when I created it, it got an id of 4, but we need to update it because the original id was 2, so we'll change the user over to that, and we're back in the same state we were before deleting the record, and you can see kind of how much trouble it causes when you delete records like that. So going back out to the forum threads index, let's go and grab the ForumThread.last, which is our "episode 26 announcement", so we're going to delete this forum thread ForumThread.last.destroy. So we did that, but the record came back, it still exists, and this time it has the deleted_at timestamp. If we come back to our page, you can see that it disappeared, and the reason it disappeared is because paranoia sets a default scope, which isn't mentioned in the README here, but there is a handful of other scopes that you can have. So there's with_deleted, only_deleted, and these will give you either all records, or only the deleted records, and by default the scope is modified so that you only get undeleted records. Now we need to take a little bit look at this and see what happens. Now we deleted id number 3, and we go and view that page, it's no longer available. This has actually changed our full scope here, so when we say ForumThread.find(3), it can't find it and we get this error, and the error is "ActiveRecord::RecordNotFound" one, so it couldn't find an id 3, but if you notice here, it's also adding the condition that the deleted_at column needs to be null. So imagine that you're having an admin account on the application, and you want to say ForumThread.with_deleted.find(3), and you'll actually get it back, so maybe admins use the with_deleted scope, and the rest of the users only see stuff that is not deleted, and maybe you have a section in your application that is archived threads and maybe those are the ones that are all listed there, so only the deleted ones are listed in your archive section. This is really nifty because it allows us to do a lot of stuff like this, and what if we want to restore that Forum Thread? ForumThread.restore(3), and this now updates the record's deleted_at to nil, so it comes back. Now we want to go and see how to delete users because that's what we started with, and forum threads is a little bit simpler because we're just accessing the records directly from the model. Now with this, though, we're going through the thread or the post, and we're trying to talk to the association, and we have to update and override our association to handle this, because we need to include with_deleted in this case. Let's go ahead and add our migration in here

railg g migration AddDeletedAtToUsers deleted_at:timestamp


def change 
    add_column :users, :deleted_at, :timestamp 
    add_index :users, deleted_at 

rake db:migrate

Now our users will be able to be soft deleted, and we'll have to go to the user model and add in the acts_as_paranoid method in here, and once we've done that, now we can run our rails console and gran that last user


They've been deleted, and now when we visit this page, we get the same error that we started with, and that's a little annoying, because we know the record exists, and in this case, it's a public forum so if you delete your account, that's fine, but we can still keep your post up or show your name, we'll just go with that, because the content is public anyways. In some cases, you may want to truly delete it, an replace these with placeholders, or just not show this post for example, but in our case we want to display the content even if it's deleted or not, and the way that we need to do that is to open up the app/models/forum_post.rb, and we need to override the getter that the belongs_to user sets for us. If you understand how belongs_to works, you have a def user =(), and it takes some arguments, and then you have a def user that doesn't take any arguments, and this is what returns the User.find on the user id, so this User find getter is basically just calling that using the user_id column. So it's as simple as that, it looks at the symbol, adds "_id", and then bascially generates a method behind the scenes that does that user = is very similar, it passes those your options over to User.create, it saves the user id into a variable, and the updates the forum posts with that id and so on. You can create the user methods like that, but you can also override them. So belongs_to, you could create by hand if you wanted, but we have this helpful association shortcut, but it doesn't allow us to find users that are deleted, so we can say

def user 

instead, and this should allow us to refresh the page and now it shows "Test User" there still, so that's cool. This is one way of doing it, the example in their documentation in the paranoia gem is to use User.unscoped { super }, which I think is a better solution

def user 
    User.unscoped { super }

Super calls the original method that we're overriding, and that original method would be from the belongs_to If we come back and refresh this page, it works as we expected, and I would recommend using their example to do this, because the unscoped basically removes the deleted_at = null condition, and then it calls with the original query. Now the way you implement this is going to be very application-specific, if you're doing things like Stripe would with payments, you probably want to keep those records around, on the other hand, if you're doing something like a forum, you may want to actually delete the user's account permanently. Paranoia is a perfect gem to straddle that balance, you can use it at will on any models that you want, and you have all of these helper methods and other things like this, really_destroy! to actually delete the record. It provides all of those things that you could want from a soft delete library, and I'm really really happy with it, they've done a great job on it, so I hope this helps and you're able to add soft deletes into your applications as well.

Transcript written by Miguel



Great episode. Thanks, Chris!


Whcih Gem do you use for the Error and Console on the Browser?


its better_errors gem, he has already done an episode about it (


Yes, but paranoia uses default_scope, which many people don't recommend. Here is some good discussion on why:


Correct for most cases, but when you don't want to expose any deleted records, default_scope is perfect for this.


Love this gem. We use it on a couple projects at work, and it's been great!


Great episode. I might try to put a method like this in user model to get a user name from deleted user.

def name
if deleted_at
'Deleted User'

I like the idea of using deleted_at to have a timestamp and status at the same time.



Hi Chris, Great video as always.

I'm trying to prevent the users avatar being fully deleted, when the soft delete occurs. I've read documentation to suggest that adding has_attached_file :avatar, preserve_files: true but this doesn't seem to work. I was hoping that you may have an idea why?




Hey Gareth, when you do a soft delete, there should be nothing that happens other than a database field called deleted_at getting set. This won't affect images at all because they should only get removed when destroyed. Are the images actually getting removed?


Would you be able to give some insight into how to really destroy a record using this gem.

Their documentation says "If you wish to actually destroy an object you may call really_destroy!. WARNING: This will also really destroy all dependent: :destroy records, so please aim this method away from face when using."

So I have it defined in my controller...but what I don't get is when I have the link in my view what method can I use to really destroy it?

Isn't method :destroy just going to try and soft delete it again?


Basically you can take let your normal destroy action call the soft delete method. You'll always want to use that by default.

Then, if you want to add a way to permanently delete it, you can add another route like:

resources :blog_posts do
member do
delete :really_destroy

And then your controller action for this new route can call @blog_post.really_destroy!


Chris, thanks for the reply.

I feel like I am darn close. I just need to get the route right.


Right, so you'd have links to the two different delete urls like this:

<%= link_to "Soft Delete", blog_post_path(@blog_post), method: :delete %>
<%= link_to "Permanently Delete", really_destroy_blog_post_path(@blog_post), method: :delete %>


Hi guys iI need to use this gem but there is an issue with the dependent destroy.
eg: a has many b
paranoid is added to model a
I m able to restore record of a
but i lost all the associated record from model b
Is there a way to soft delete records of model b when I soft delete record of a.

Ristovski Vlatko

You need to add soft delete on the b model. What I mean is, create a column `deleted_at` (or whatever you want since paranoia supports that option too) and call the class method `acts_as_paranoid` (also if you named your column different than `deleted_at` you should specify it here, just be sure to check the README and then the destroy will be recursive on the associations.
What I mean is when you call `a.destroy` if you have `has_many :b, dependent: :destroy` then it will call `b.destroy` on all of the b models associated with model a

Login or create an account to join the conversation.