Click here to Skip to main content
Click here to Skip to main content

Retrieving failed records after an SqlBulkCopy exception

By , 19 Jun 2012
 

Introduction

Let me start by saying that the idea I used in this article is not originally mine, but since I have only heard of it and have not been able to find any actual examples of its implementation anywhere I wrote the code to handle it.

With that out of the way - here's what this is about: Anyone who's worked with .NET's SqlBulkCopy class knows how fast and powerful it is. It beats other mechanisms for pumping large quantities of data into an SQL Server database by huge factors, and one of the reasons it is so fast is that it does not log anything it does.

The lack of logging definitely speeds things up, but when you are pumping hundreds of thousands of rows and suddenly have a failure on one of them because of a constraint, you're stuck. All the SqlException will tell you is that something went wrong with a given constraint (you'll get the constraint's name at least), but that's about it. You're then stuck having to go back to your source, run separate SELECT statements on it (or do manual searches), and find the culprit rows on your own.

On top of that, it can be a very long and iterative process if you've got data with several potential failures in it because SqlBulkCopy will stop as soon as the first failure is hit. Once you correct that one, you need to rerun the load to find the second error, etc.

The approach described in this article has the following advantages: 

  • Reports all possible errors that the SqlBulkCopy would encounter
  • Reports all culprit data rows, along with the exception that row would be causing
  • The entire thing is run in a transaction that is rolled back at the end, so no changes are committed.

... and disadvantages:

  • For extremely large amounts of data it might take a couple of minutes.
  • This solution is reactive; i.e. the errors are not returned as part of the exception raised by your SqlBulkCopy.WriteToServer() process. Instead, this helper method is executed after the exception is raised to try and capture all possible errors along with their related data. This means that in case of an exception, your process will take longer to run than just running the bulk copy.
  • You cannot reuse the same DataReader object from the failed SqlBulkCopy, as readers are forward only fire hoses that cannot be reset. You'll need to create a new reader of the same type (e.g. re-issue the original SqlCommand, recreate the reader based on the same DataTable, etc).

Background

The main idea is quite simple. Rerun the bulk copy, but only process one row at a time. As the rows are processed, capture the individual exceptions that copying them raises (if any) and add both the message and the row's data to an incremental message, but don't stop copying the data to the server. When all is said and done, your final error message is a nice log showing all the issues and the data that caused them. From that point it's easy to go back to the source, find those records, fix the issues and then reissue the bulk copy.

Using the code

It's important to note that not all failures on a bulk copy happen because of data. You might have connectivity issues, authentication failures, timeouts, etc. None of these cases would be explained by your data, so there's no point in calling this helper method if this is your case. You need to take this into account when calling the helper method, and only call it for specific types of exceptions (the sample code below takes care of this).

Also consider that the exception you're catching may not necessarily be the one raised by SqlServer and could be contained within an inner Exception. So if you plan on calling the helper method only if a data-related issue occurred, the exception (and all inner exceptions) needs to be inspected for this. The sample code below takes care of this, even though the Exception is coming directly from the server; in your case, you might be handling it at a higher level after the it has been wrapped in other exceptions.

Test bulk copy method

TestMethod() below is a simple method that sets up for a bulk copy operation and encloses it in a try/catch block. It is this bulk copy that supposedly fails because of some data issue, so within the catch block we then check the exception (and all inner exceptions) for a message containing the word "constraint" (which is apparently the only way to find a constraint failure, as all exceptions from SqlServer are of type SqlException). If such an exception message is found, we call GetBulkCopyFailedData() in order to get the failed rows. This latter method would ideally reside in a separate helper-type class.

Granted, this checking could have been done within the helper, but I was trying to keep it generic enough so that would show all exceptions and not assume what the caller wanted to filter out.

private void TestMethod()
{
   // new code
   SqlConnection connection = null;
   SqlBulkCopy bulkCopy = null;
   
   DataTable dataTable = new DataTable();
   // load some sample data into the DataTable
   IDataReader reader = dataTable.CreateDataReader();
 
   try 
   {
      connection = new SqlConnection("connection string goes here ...");
      connection.Open();
      bulkCopy = new SqlBulkCopy(connection); 
      bulkCopy.DestinationTableName = "Destination table name";
      bulkCopy.WriteToServer(reader);
   }
   catch (Exception exception)
   {
      // loop through all inner exceptions to see if any relate to a constraint failure
      bool dataExceptionFound = false;
      Exception tmpException = exception;
      while (tmpException != null)
      {
         if (tmpException is SqlException
            && tmpException.Message.Contains("constraint"))
         {
            dataExceptionFound = true;
            break;
         }
         tmpException = tmpException.InnerException;
      }

      if (dataExceptionFound)
      {
         // call the helper method to document the errors and invalid data
         string errorMessage = GetBulkCopyFailedData(
            connection.ConnectionString,
            bulkCopy.DestinationTableName,
            dataTable.CreateDataReader());
         throw new Exception(errorMessage, exception);
      }
   }
   finally
   {
      if (connection != null && connection.State == ConnectionState.Open)
      {
         connection.Close();
      }
   }
}

Documenting the errors and faulty data rows

GetBulkCopyFailedData() then opens a new connection to the database, creates a transaction, and begins bulk copying the data one row at a time. It does so by reading through the supplied DataReader and copying each row into an empty DataTable. The DataTable is then bulk copied into the destination database, and any exceptions resulting from this are caught, documented (along with the DataRow that caused it), and the cycle then repeats itself with the next row.

At the end of the DataReader we rollback the transaction and return the complete error message. Fixing the problems in the data source should now be a breeze.

/// <summary>
/// Build an error message with the failed records and their related exceptions.
/// </summary>
/// <param name="connectionString">Connection string to the destination database</param>
/// <param name="tableName">Table name into which the data will be bulk copied.</param>
/// <param name="dataReader">DataReader to bulk copy</param>
/// <returns>Error message with failed constraints and invalid data rows.</returns>
public static string GetBulkCopyFailedData(
   string connectionString,
   string tableName,
   IDataReader dataReader)
{
   StringBuilder errorMessage = new StringBuilder("Bulk copy failures:" + Environment.NewLine);
   SqlConnection connection = null;
   SqlTransaction transaction = null;
   SqlBulkCopy bulkCopy = null;
   DataTable tmpDataTable = new DataTable();
   
   try
   { 
      connection = new SqlConnection(connectionString); 
      connection.Open();
      transaction = connection.BeginTransaction();
      bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.CheckConstraints, transaction);
      bulkCopy.DestinationTableName = tableName;
      
      // create a datatable with the layout of the data.
      DataTable dataSchema = dataReader.GetSchemaTable();
      foreach (DataRow row in dataSchema.Rows)
      {
         tmpDataTable.Columns.Add(new DataColumn(
            row["ColumnName"].ToString(), 
            (Type)row["DataType"]));
      }
      
      // create an object array to hold the data being transferred into tmpDataTable 
      //in the loop below.
      object[] values = new object[dataReader.FieldCount];

      // loop through the source data
      while (dataReader.Read())
      {
         // clear the temp DataTable from which the single-record bulk copy will be done
         tmpDataTable.Rows.Clear();

         // get the data for the current source row
         dataReader.GetValues(values);

         // load the values into the temp DataTable
         tmpDataTable.LoadDataRow(values, true);

         // perform the bulk copy of the one row
         try
         {
            bulkCopy.WriteToServer(tmpDataTable);
         }
         catch (Exception ex)
         {
            // an exception was raised with the bulk copy of the current row. 
            // The row that caused the current exception is the only one in the temp 
            // DataTable, so document it and add it to the error message.
            DataRow faultyDataRow = tmpDataTable.Rows[0];
            errorMessage.AppendFormat("Error: {0}{1}", ex.Message, Environment.NewLine);
            errorMessage.AppendFormat("Row data: {0}", Environment.NewLine);
            foreach (DataColumn column in tmpDataTable.Columns)
            {
               errorMessage.AppendFormat(
                  "\tColumn {0} - [{1}]{2}", 
                  column.ColumnName, 
                  faultyDataRow[column.ColumnName].ToString(), 
                  Environment.NewLine);
            }
         }
      }
   }
   catch (Exception ex) 
   { 
      throw new Exception(
         "Unable to document SqlBulkCopy errors. See inner exceptions for details.", 
         ex); 
   }
   finally
   {
      if (transaction != null)
      {
         transaction.Rollback();
      }
      if (connection.State != ConnectionState.Closed)
      {
         connection.Close();
      }
   }
   return errorMessage.ToString();
}

Conclusion

I've certainly wasted more than enough time trying to figure out what was wrong with my data because the bulk copy operation wouldn't help me out there, so I hope this helps avoid wasted time for someone else as well.

As always - comments, questions and suggestions are always welcome. And please don't forget to vote! 

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

About the Author

David Catriel
Team Leader
Canada Canada
Member
A developer that's been tinkering with computers since he first laid eyes on his buddy's Atari in the mid 80's and messed around with GWBasic and Logo. He now divides his time among his wife, kids, and evil mistress (a term lovingly [ahem...] given to his computer by the wife ...).

Sign Up to vote   Poor Excellent
Add a reason or comment to your vote: x
Votes of 3 or less require a comment

Comments and Discussions

 
You must Sign In to use this message board.
Search this forum  
    Spacing  Noise  Layout  Per page   
GeneralMy vote of 5mvpKanasz Robert25 Sep '12 - 22:51 
Very good job Smile | :)
GeneralRe: My vote of 5memberDavid Catriel25 Sep '12 - 23:21 
Thx!
 
David.
http://www.linkedin.com/pub/david-catriel/44/b01/382
GeneralMy vote of 5memberFranciscoLeon15 Jun '12 - 10:26 
Amazing article, easy to read and it helped me solve an issue I had on my application!
GeneralRe: My vote of 5memberDavid Catriel15 Jun '12 - 10:57 
Glad you liked Smile | :)
 
David.
http://www.linkedin.com/pub/david-catriel/44/b01/382
QuestionNicemembermikeperetz12 Jun '12 - 17:52 
Simple, solves the problem.
"There are only 10 types of people in the world: Those who understand binary, and those who don't"
More articles on my blog

GeneralMy vote of 5membercjb11020 May '12 - 21:24 
Simple idea, but useful!
QuestionDo batches rather than every record individuallymemberMichael197320 May '12 - 1:04 
I had to solve a similar problem myself. In order to keep things relatively fast I would do the Bulk Copies in batches of 1000 to 10000 records. If no errors were generated, great! Those records were all "good". If there was an exception then I'd iterate through just that batch record by record in order to find the actual culprits.
 
Over all this kept the speed up to reasonable levels, even in those cases where the data being copied was a bit dodgy.
AnswerRe: Do batches rather than every record individuallymemberDavid Catriel20 May '12 - 2:43 
That works, except that if you have multiple failures in more than one batch you need to redo your process over and over again and until they've all been found. With this technique you only search once and find everything in one shot. You save a lot of time this way.
 
David.
http://www.linkedin.com/pub/david-catriel/44/b01/382
GeneralRe: Do batches rather than every record individuallymemberMichael197320 May '12 - 4:39 
Not true. If there's a failure in the batch, you check all the records in the batch. You'd only miss a problem if stopped looking as soon as you find your first error. This is why setting a sensible batch size is important.
 
The only errors you can miss doing what I've described is an individual record having more than one thing wrong with it, and your method won't pick that up either. (Nor indeed could I think of a method that would that didn't involve writing a whole lot of business rules to be checked for, etc. which is exactly what we're trying to avoid.)
 
Cheers,
 
Michael
GeneralRe: Do batches rather than every record individuallymemberDavid Catriel20 May '12 - 7:39 
I don't think I'm getting my point across. You approach would find the problems in the batch, but you still would have to run your process several more times if there are issues in other batches. The approach I'm using finds all errors from the entire bulk update in one shot, regardless of how you partition your batches.
 
As for multiple errors on the same record - yes, I wouldn't be able to catch that either. I guess not everything can be automated.
 
David.
http://www.linkedin.com/pub/david-catriel/44/b01/382
GeneralRe: Do batches rather than every record individuallymembersupercat920 May '12 - 13:53 
I think the idea is that if there are 100,000 records and e.g. ten of them have problems, it would be far better to do 90 successful batches of 1,000 records each, ten unsuccessful batches of 1,000 records, and 10,000 individual records, than to do one unsuccessful batch of 100,000 records and 100,000 individual records.
GeneralRe: Do batches rather than every record individuallymemberDavid Catriel20 May '12 - 15:02 
supercat, that's exactly the problem. You have no idea what the distribution of the bad records is, so if in your example you have ten unsuccessful batches the bulk copy will stop on the first of those ten. Once you've corrected your errors from that batch you'll need to rerun the whole thing again and then stop a second time because of errors in the second batch. And so on until the tenth batch. At this point, you've rerun the entire thing ten times and have probably punched the monitor in aggravation because of the seemingly unending errors.
What I'm doing is saying "ok, so I've found an error. I don't intend to run through this whole update n times just find the others. I'm going to run it once, get all the errors, and correct them in one shot". I find this is much more efficient and also gives you a much clearer picture of how many issues your data has as a whole.
 
David.
http://www.linkedin.com/pub/david-catriel/44/b01/382
GeneralRe: Do batches rather than every record individuallymembercjb11020 May '12 - 21:23 
Assuming there's a failure somewhere, wouldn't the overall speed really depend on the speed of GetBulkCopyFailedData?
 
That method is going to read every single record, and going down the batch route it would only do that for those batches that contained a failure. So 10k records, 1k batches, 3 errors would worst case read 3k rows. No batches, would always read the full 10k.
 
I guess it depends on how many errors your expecting, and if your doing bulk ops I think that should be none...if your expecting errors bulk into a staging table (with less restrictions) first, then clean.
 
But for the occasional error this method would get the majority into the db, and let you know the exact row...so good job Smile | :)
GeneralRe: Do batches rather than every record individuallymemberDavid Catriel21 May '12 - 2:18 
Yes, the overall speed would definitely depend on running GetBulkCopyFailedData.
 
Re batches - I guess it depends on how you manage your batches. Are you referring to loading completely independent batches, where every one of them is being run within its own transaction? In that case, I suppose your method works. My personal preference, however, is to get a complete list of all the failures up front instead of having to rerun the process until everything is done. Also, I don't think that running separate batches would give you any particular speed or time advantage. You would be saving time if there are some failures (so you don't have to rerun batches that were already saved), so I guess that's an advantage. In the end, however, I think the methods don't differ much in time; they will probably come down to personal preferences.
 
Loading everything into a staging table is an excellent way of cleaning things up before loading into your final database. Your staging area would ideally contain the same data constraints as your final database, so you might be running into issues while loading into the staging area.
 
David.
http://www.linkedin.com/pub/david-catriel/44/b01/382
GeneralRe: Do batches rather than every record individuallymemberMichael197321 May '12 - 16:16 
You are quite incorrect in your assumptions. I am not rerunning the process multiple times each time I find an error.
 
Here's my assumption: The data is mostly good. I'm not going into the process blind, I have at least some confidence in the data I'm importing and I expect it to mostly import fine.
 
Now an example: I need to import 100,000 records, and scattered in there are 10 bad records. I start the process in batches of 1,000 records, so if the data was perfect there would be 100 bacthes and I'm done. But because there are errors, 10 (or less, if errors are "clustered") batches will fail. The moment a batch fails the program shifts to re-doing that particular batch record-by-record, as per your example. When an individual record fails it can be noted to a log somewhere, but the process continues, record-by-record for the rest of that batch, then returns to doing 1,000 record batches.
 
At the end of all this I have a log file with 10 records in it (hopefully identified by a primary key), and I have 99,990 good records in my database. All that good data got there with 100 batches of 1,000 records transferred, 10 of which failed, and 10,000 records individually transferred, of which 10 failed. I fix the 10 records and import them - job done.
 
Yes, in total I have sent 110,010 records with 10,010 rejected, compared to your 100,010 records with 10 rejected, but in my experience, the time spent resending the failed batches automatically (I don't know where you got the idea that I'm stopping, fixing a record, running it again, stopping again, etc. from) is easily compensated for by the time saved by doing 90% of the work in larger batches. I mean, otherwise what is the point of using the BulkCopyTransfer process at all? Is doing 100,000 single record BulkCopyTransfers any faster than doing 100,000 single record Inserts? (Possibly it is, marginally, since logging isn't done, triggers aren't fired, etc. This is a genuine question which I don't know the answer to... Confused | :confused: )
 
And if I had less confidence in the data I could do batches of 500, or 100, and probably still save time. (Once you get down to batches of 10 or so then your data is crap and you might as well do it record-by-record, I agree.)
 
Fundamentally you and I are agreeing Smile | :) on the technique of getting around the issue of BulkCopyTransfer not beiung very helpful in its error messages, and I think your article is a good one, so don't think I'm ripping your idea to shreads. I've just introduced an extra bit to the recipe which in my experience speeds things up (at least in the case of "mostly good" input data) with no downsides.
 
Cheers,
 
Michael
GeneralRe: Do batches rather than every record individuallymemberDavid Catriel22 May '12 - 9:56 
Yes, looks like my assumptions were off. I've never really tried updating a table with multiple batches of a bulk copy because if there were problems I would then have to somehow filter out the data that had already been loaded or just re-update those rows with no changes. A bulk load, for me, has always been an all-or-nothing process. I guess there is simply more than one way to skin this cat.
 
As for ripping the idea - no worries there. I'm always open to a good discussion and exchange of ideas; that's what helps us keep on learning.
 
P.S. no cats were harmed in the writing of this comment.
 
David.
http://www.linkedin.com/pub/david-catriel/44/b01/382
Generalsqlbulkcopy imports only 17837 out of 67000 rowsmemberGudi Nabar22 Jan '13 - 10:50 
I've an application that imports excel files into SQL server. The application works fine on my local machine and it imports all rows perfectly. However, when I deploy this to the server IIS 7.0 it works for small files but for larger ones imports partial data. Also, it does not throw any exception. For example it imported only 17837 out of 67456 rows in one of the files and there's no pattern cause it imports different (yet, less) data for each large file.
 

The only fishy thing I see is that, one of the columns is "NULL" for the very last row that was converted.
 
Original 17837 row : 358487 12/12/2012 N NULL #87726 UHC NULL
 
Converted 17837 row 358487 12/12/2012 N N/A Per Provider #87726 UHC NULL
 
Can you please put some light on what can be the issue here?
GeneralRe: sqlbulkcopy imports only 17837 out of 67000 rowsmemberDavid Catriel22 Jan '13 - 11:08 
Hi Gudi
There's nothing wrong in having a NULL in a column. You'd need to check the column's definition first to tell if that NULL is allowed or not.
 
As for the bulk copy failure - there must be a log of it somewhere. Either within IIS or under the computer event viewer.
 
In any case - if you can't tell what's going, I suggest implementing the code from the article and just log the exceptions to a file of your choice when they occur. Then you'll have all the info you need. You need some kind of log to give you an idea of the problem; without it, there's really no way anyone can help you figure this out.
 
David.
http://www.linkedin.com/pub/david-catriel/44/b01/382
GeneralRe: sqlbulkcopy imports only 17837 out of 67000 rowsmemberGudi Nabar22 Jan '13 - 11:28 
Actually, I implemented your method, but that gave no exceptions. So there's no log of anything erroring out. That is really my confusion. It's almost like sqlbulkcopy only sees those many rows to be imported and imports them without error, except for the very last one.
GeneralRe: sqlbulkcopy imports only 17837 out of 67000 rowsmemberDavid Catriel22 Jan '13 - 13:38 
That is strange. I would focus on one of the files causing you problems. Does reimporting it a couple of times always fail on the same record? If so, compare that record's values with the schema of the table and see which one is failing the constraints.
 
If you have Enterprise Manager you could also try importing the file by using an Excel ODBC pipe. It will be way slower than a bulk copy, but might give you an idea of what's wrong.
 
David.
http://www.linkedin.com/pub/david-catriel/44/b01/382
GeneralRe: sqlbulkcopy imports only 17837 out of 67000 rowsmemberGudi Nabar23 Jan '13 - 4:50 
Actually, it was the datatable that was being loaded from the excel file that is creating the issue. The datatable itself has less rows when populated on the server and hence, sqlbulkcopy is only seeing those many rows. Thanks for your prompt input.
GeneralRe: sqlbulkcopy imports only 17837 out of 67000 rowsmemberDavid Catriel23 Jan '13 - 5:22 
NP. Glad you found the issue.
 
David.
http://www.linkedin.com/pub/david-catriel/44/b01/382

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Permalink | Advertise | Privacy | Mobile
Web03 | 2.6.130523.1 | Last Updated 19 Jun 2012
Article Copyright 2012 by David Catriel
Everything else Copyright © CodeProject, 1999-2013
Terms of Use
Layout: fixed | fluid