diff --git a/modules/system/system.install b/modules/system/system.install
index 114240ed871bd2e9e2187db131da0400097a454c..1f3c2b0f0b4a16546266ea8837cabc11badfded3 100644
--- a/modules/system/system.install
+++ b/modules/system/system.install
@@ -2213,27 +2213,7 @@ function system_update_7034() {
  * Migrate upload module files to the new {file_managed} table.
  */
 function system_update_7035() {
-  if (!db_table_exists('upload')) {
-    return;
-  }
-
-  // The old {files} tables still exists.  We migrate core data from upload
-  // module, but any contrib module using it will need to do its own update.
-  $result = db_query('SELECT f.fid, uid, filename, filepath AS uri, filemime, filesize, status, timestamp FROM {files} f INNER JOIN {upload} u ON u.fid = f.fid', array(), array('fetch' => PDO::FETCH_ASSOC));
-
-  // We will convert filepaths to uri using the default schmeme
-  // and stripping off the existing file directory path.
-  $basename = variable_get('file_directory_path', conf_path() . '/files');
-  $scheme = variable_get('file_default_scheme', 'public') . '://';
-  $fids = array();
-  // TODO: does this function need to run in batch mode, or should we use a multi-insert?
-  foreach ($result as $file) {
-    $file['uri'] = $scheme . str_replace($basename, '', $file['uri']);
-    $file['uri'] = file_stream_wrapper_uri_normalize($file['uri']);
-    db_insert('file_managed')->fields($file)->execute();
-    $fids[] = $file['fid'];
-  }
-  // TODO: delete the found fids from {files}?
+  // Update merged into system_update_7059().
 }
 
 /**
@@ -2582,6 +2562,211 @@ function system_update_7058() {
   variable_del('cron_semaphore');
 }
 
+/**
+ * Migrate upload.module to file.module.
+ */
+function system_update_7059(&$sandbox) {
+  if (!db_table_exists('upload')) {
+    return;
+  }
+
+  if (!isset($sandbox['progress'])) {
+    // Initialize batch update information.
+    $sandbox['progress'] = 0;
+    $sandbox['last_vid_processed'] = -1;
+    $sandbox['max'] = db_query("SELECT COUNT(DISTINCT u.vid) FROM {upload} u")->fetchField();
+
+    // Check which node types have upload.module attachments enabled.
+    $context['types'] = array();
+    foreach (node_type_get_types() as $node_type => $node_info) {
+      if (variable_get('upload_' . $node_type, 1)) {
+        $context['types'][$node_type] = $node_type;
+      }
+      variable_del('upload_' . $node_type);
+    }
+
+    // The {upload} table will be deleted when this update is complete so we
+    // want to be careful to migrate all the data, even for node types that
+    // may have had attachments disabled after files were uploaded. Look for
+    // any other node types referenced by the upload records and add those to
+    // the list. The admin can always remove the field later.
+    $results = db_query('SELECT DISTINCT type FROM {node} n INNER JOIN {upload} u ON n.vid = u.vid');
+    foreach ($results as $row) {
+      if (!isset($context['types'][$row->type])) {
+        drupal_set_message('The content type <em>' . $row->type . '</em> had uploads disabled but contained uploaded file data. Uploads have been re-enabled to migrate the existing data. You may delete the "File attachments" field in the <em>' . $row->type . '</em> type if this data is not necessary.');
+        $context['types'][$row->type] = $row->type;
+      }
+    }
+
+    // Create a single "field_upload" field on all the content types that have
+    // uploads enabled, then add an instance to each enabled type.
+    if (count($context['types']) > 0) {
+      module_enable(array('file'));
+      module_load_include('inc', 'field', 'field.crud');
+
+      $field = array(
+        'field_name' => 'file',
+        'type' => 'file',
+        'locked' => FALSE,
+        'cardinality' => FIELD_CARDINALITY_UNLIMITED,
+        'translatable' => FALSE,
+        'settings' => array(
+          'display_field' => 1,
+          'display_default' => variable_get('upload_list_default', 1),
+          'uri_scheme' => variable_get('file_default_scheme', 'public'),
+          'default_file' => 0,
+        ),
+      );
+
+      $upload_size = variable_get('upload_uploadsize_default', 1);
+      $instance = array(
+        'field_name' => 'file',
+        'entity_type' => 'node',
+        'bundle' => NULL,
+        'label' => 'File attachments',
+        'widget_type' => 'file_generic',
+        'required' => 0,
+        'description' => '',
+        'widget' => array(
+          'weight' => '1',
+          'settings' => array(
+            'progress_indicator' => 'throbber',
+          ),
+          'type' => 'file_generic',
+        ),
+        'settings' => array(
+          'max_filesize' => $upload_size ? ($upload_size . ' MB') : '',
+          'file_extensions' => variable_get('upload_extensions_default', 'jpg jpeg gif png txt doc xls pdf ppt pps odt ods odp'),
+          'file_directory' => '',
+          'description_field' => 1,
+        ),
+        'display' => array(
+          'full' => array(
+            'label' => 'hidden',
+            'type' => 'file_table',
+            'settings' => array(),
+            'weight' => 0,
+            'module' => 'file',
+          ),
+          'teaser' => array(
+            'label' => 'hidden',
+            'type' => 'hidden',
+            'settings' => array(),
+            'weight' => 0,
+            'module' => NULL,
+          ),
+          'rss' => array(
+            'label' => 'hidden',
+            'type' => 'file_table',
+            'settings' => array(),
+            'weight' => 0,
+            'module' => 'file',
+          ),
+        ),
+      );
+
+      // Create the field. Save the field id for the data insertion later on.
+      $field = field_create_field($field);
+      $sandbox['field_id'] = $field['id'];
+
+      // Create the instances.
+      foreach ($context['types'] as $bundle) {
+        $instance['bundle'] = $bundle;
+        field_create_instance($instance);
+      }
+    }
+    else {
+      // No uploads or content types with uploads enabled.
+      db_drop_table('upload');
+      // We're done: return without specifying a #progress.
+      return;
+    }
+  }
+
+  // Migrate a batch of files from the upload table to the appropriate field.
+  $limit = 500;
+  $result = db_query_range('SELECT DISTINCT u.fid, u.vid, u.list, u.description, n.nid, n.type FROM {upload} u INNER JOIN {node_revision} nr ON u.vid = nr.vid INNER JOIN {node} n ON n.nid = nr.nid WHERE u.vid > :lastvid ORDER BY u.vid, u.weight', 0, $limit, array(':lastvid' => $sandbox['last_vid_processed']));
+  foreach ($result as $record) {
+    // Note that we still reference the old files table here, since upload will
+    // not know about the new FID in the new file_managed table.
+    $file = db_select('files', 'f')
+      ->fields('f', array('fid', 'uid', 'filename', 'filepath', 'filemime', 'filesize', 'status', 'timestamp'))
+      ->condition('f.fid', $record->fid)
+      ->execute()
+      ->fetchAssoc();
+    if (!$file) {
+      continue;
+    }
+
+    $file['description'] = $record->description;
+    $file['display'] = $record->list;
+
+    $node_revisions[$record->vid]['nid'] = $record->nid;
+    $node_revisions[$record->vid]['vid'] = $record->vid;
+    $node_revisions[$record->vid]['type'] = $record->type;
+    $node_revisions[$record->vid]['file'][LANGUAGE_NONE][] = $file;
+  }
+
+  // To make sure we process an entire node all at once, toss the last node
+  // revision (which might be partial) unless it's the last one.
+  if ((count($node_revisions) > 1) && ($result->rowCount() == $limit)) {
+    array_pop($node_revisions);
+  }
+  else {
+    $finished = TRUE;
+  }
+
+  $basename = variable_get('file_directory_path', conf_path() . '/files');
+  $scheme = variable_get('file_default_scheme', 'public') . '://';
+  foreach ($node_revisions as $vid => $revision) {
+    // We will convert filepaths to uri using the default scheme
+    // and stripping off the existing file directory path.
+    $fids = array();
+    foreach ($revision['file'][LANGUAGE_NONE] as $delta => $file) {
+      // Insert into the file_managed table.
+      $file['uri'] = $scheme . str_replace($basename, '', $file['filepath']);
+      $file['uri'] = file_stream_wrapper_uri_normalize($file['uri']);
+      unset($file['filepath']);
+      // Each fid should only be stored once in file_managed.
+      db_merge('file_managed')
+        ->key(array(
+          'fid' => $file['fid'],
+        ))
+        ->fields(array(
+          'uid' => $file['uid'],
+          'filename' => $file['filename'],
+          'uri' => $file['uri'],
+          'filemime' => $file['filemime'],
+          'filesize' => $file['filesize'],
+          'status' => $file['status'],
+          'timestamp' => $file['timestamp'],
+        ))
+        ->execute();
+
+      // Update the node field with the file URI.
+      $revision['file'][LANGUAGE_NONE][$delta] = $file;
+    }
+
+    // Insert the revision's files into the field_upload table.
+    $node = (object) $revision;
+    field_sql_storage_field_storage_write('node', $node, FIELD_STORAGE_INSERT, array($sandbox['field_id']));
+
+    // Update our progress information for the batch update.
+    $sandbox['progress']++;
+    $sandbox['last_vid_processed'] = $vid;
+  }
+
+  // If there's no max value then there's nothing to update and we're finished.
+  if (empty($sandbox['max']) || isset($finished)) {
+    db_drop_table('upload');
+    return t('Upload module has been migrated to File module.');
+  }
+  else {
+    // Indicate our current progress to the batch update system.
+    $sandbox['#finished'] = $sandbox['progress'] / $sandbox['max'];
+  }
+}
+
 /**
  * @} End of "defgroup updates-6.x-to-7.x"
  * The next series of updates should start at 8000.